feat: add restore deleted document dialog

This commit is contained in:
Ephraim Atta-Duncan
2025-03-13 22:09:07 +00:00
parent 27cd8f9c25
commit c560b9e9e3
10 changed files with 323 additions and 14 deletions

View File

@ -0,0 +1,119 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
type DocumentRestoreDialogProps = {
id: number;
open: boolean;
onOpenChange: (_open: boolean) => void;
onRestore?: () => Promise<void> | void;
documentTitle: string;
teamId?: number;
canManageDocument: boolean;
};
export const DocumentRestoreDialog = ({
id,
open,
onOpenChange,
onRestore,
documentTitle,
canManageDocument,
}: DocumentRestoreDialogProps) => {
const { toast } = useToast();
const { refreshLimits } = useLimits();
const { _ } = useLingui();
const { mutateAsync: restoreDocument, isPending } =
trpcReact.document.restoreDocument.useMutation({
onSuccess: async () => {
void refreshLimits();
toast({
title: _(msg`Document restored`),
description: _(msg`"${documentTitle}" has been successfully restored`),
duration: 5000,
});
await onRestore?.();
onOpenChange(false);
},
onError: () => {
toast({
title: _(msg`Something went wrong`),
description: _(msg`This document could not be restored at this time. Please try again.`),
variant: 'destructive',
duration: 7500,
});
},
});
return (
<Dialog open={open} onOpenChange={(value) => !isPending && onOpenChange(value)}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Restore Document</Trans>
</DialogTitle>
<DialogDescription>
{canManageDocument ? (
<Trans>
You are about to restore <strong>"{documentTitle}"</strong>
</Trans>
) : (
<Trans>
You are about to unhide <strong>"{documentTitle}"</strong>
</Trans>
)}
</DialogDescription>
</DialogHeader>
<Alert variant="neutral" className="-mt-1">
<AlertDescription>
{canManageDocument ? (
<Trans>
The document will be restored to your account and will be available in your
documents list.
</Trans>
) : (
<Trans>
The document will be unhidden from your account and will be available in your
documents list.
</Trans>
)}
</AlertDescription>
</Alert>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Cancel</Trans>
</Button>
<Button
type="button"
loading={isPending}
onClick={() => void restoreDocument({ documentId: id })}
>
<Trans>Restore</Trans>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@ -25,7 +25,7 @@ export const DocumentPageViewInformation = ({
const { _, i18n } = useLingui();
const documentInformation = useMemo(() => {
return [
const documentInfo = [
{
description: msg`Uploaded by`,
value:
@ -44,6 +44,19 @@ export const DocumentPageViewInformation = ({
.toRelative(),
},
];
if (document.deletedAt) {
documentInfo.push({
description: msg`Deleted`,
value:
document.deletedAt &&
DateTime.fromJSDate(document.deletedAt)
.setLocale(i18n.locales?.[0] || i18n.locale)
.toFormat('MMMM d, yyyy'),
});
}
return documentInfo;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isMounted, document, userId]);

View File

@ -15,6 +15,7 @@ import {
MoreHorizontal,
MoveRight,
Pencil,
RotateCcw,
Share,
Trash2,
} from 'lucide-react';
@ -39,6 +40,7 @@ import { DocumentDeleteDialog } from '~/components/dialogs/document-delete-dialo
import { DocumentDuplicateDialog } from '~/components/dialogs/document-duplicate-dialog';
import { DocumentMoveDialog } from '~/components/dialogs/document-move-dialog';
import { DocumentResendDialog } from '~/components/dialogs/document-resend-dialog';
import { DocumentRestoreDialog } from '~/components/dialogs/document-restore-dialog';
import { DocumentRecipientLinkCopyDialog } from '~/components/general/document/document-recipient-link-copy-dialog';
import { useOptionalCurrentTeam } from '~/providers/team';
@ -58,18 +60,20 @@ export const DocumentsTableActionDropdown = ({ row }: DocumentsTableActionDropdo
const { _ } = useLingui();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isRestoreDialogOpen, setRestoreDialogOpen] = useState(false);
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
const [isMoveDialogOpen, setMoveDialogOpen] = useState(false);
const recipient = row.recipients.find((recipient) => recipient.email === user.email);
const isOwner = row.user.id === user.id;
// const isRecipient = !!recipient;
const isRecipient = !!recipient;
const isDraft = row.status === DocumentStatus.DRAFT;
const isPending = row.status === DocumentStatus.PENDING;
const isComplete = isDocumentCompleted(row.status);
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
const isCurrentTeamDocument = team && row.team?.url === team.url;
const isDocumentDeleted = row.deletedAt !== null;
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);
const documentsPath = formatDocumentsPath(team?.url);
@ -171,10 +175,17 @@ export const DocumentsTableActionDropdown = ({ row }: DocumentsTableActionDropdo
Void
</DropdownMenuItem> */}
<DropdownMenuItem onClick={() => setDeleteDialogOpen(true)}>
<Trash2 className="mr-2 h-4 w-4" />
{canManageDocument ? _(msg`Delete`) : _(msg`Hide`)}
</DropdownMenuItem>
{isDocumentDeleted || (isRecipient && !canManageDocument) ? (
<DropdownMenuItem disabled={isRecipient} onClick={() => setRestoreDialogOpen(true)}>
<RotateCcw className="mr-2 h-4 w-4" />
<Trans>Restore</Trans>
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={() => setDeleteDialogOpen(true)}>
<Trash2 className="mr-2 h-4 w-4" />
{canManageDocument ? _(msg`Delete`) : _(msg`Hide`)}
</DropdownMenuItem>
)}
<DropdownMenuLabel>
<Trans>Share</Trans>
@ -220,6 +231,15 @@ export const DocumentsTableActionDropdown = ({ row }: DocumentsTableActionDropdo
canManageDocument={canManageDocument}
/>
<DocumentRestoreDialog
id={row.id}
open={isRestoreDialogOpen}
onOpenChange={setRestoreDialogOpen}
documentTitle={row.title}
teamId={team?.id}
canManageDocument={canManageDocument}
/>
<DocumentMoveDialog
documentId={row.id}
open={isMoveDialogOpen}

View File

@ -9,7 +9,6 @@ import { match } from 'ts-pattern';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import type { TFindDocumentsResponse } from '@documenso/trpc/server/document-router/schema';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
@ -76,13 +75,12 @@ export const DocumentsTable = ({ data, isLoading, isLoadingError }: DocumentsTab
},
{
header: _(msg`Actions`),
cell: ({ row }) =>
(!row.original.deletedAt || isDocumentCompleted(row.original.status)) && (
<div className="flex items-center gap-x-4">
<DocumentsTableActionButton row={row.original} />
<DocumentsTableActionDropdown row={row.original} />
</div>
),
cell: ({ row }) => (
<div className="flex items-center gap-x-4">
<DocumentsTableActionButton row={row.original} />
<DocumentsTableActionDropdown row={row.original} />
</div>
),
},
] satisfies DataTableColumnDef<DocumentsTableRow>[];
}, [team]);

View File

@ -0,0 +1,108 @@
import { WebhookTriggerEvents } from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { triggerWebhook } from '@documenso/lib/server-only/webhooks/trigger/trigger-webhook';
import {
ZWebhookDocumentSchema,
mapDocumentToWebhookDocumentPayload,
} from '@documenso/lib/types/webhook-payload';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
export type RestoreDocumentOptions = {
id: number;
userId: number;
teamId?: number;
requestMetadata: ApiRequestMetadata;
};
export const restoreDocument = async ({
id,
userId,
teamId,
requestMetadata,
}: RestoreDocumentOptions) => {
const user = await prisma.user.findUnique({
where: {
id: userId,
},
});
if (!user) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'User not found',
});
}
const document = await prisma.document.findUnique({
where: {
id,
},
include: {
recipients: true,
documentMeta: true,
team: {
include: {
members: true,
},
},
},
});
if (!document || (teamId !== undefined && teamId !== document.teamId)) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
});
}
const isUserOwner = document.userId === userId;
const isUserTeamMember = document.team?.members.some((member) => member.userId === userId);
if (!isUserOwner && !isUserTeamMember) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Not allowed to restore this document',
});
}
const restoredDocument = await prisma.$transaction(async (tx) => {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
documentId: document.id,
type: 'DOCUMENT_RESTORED',
metadata: requestMetadata,
data: {},
}),
});
return await tx.document.update({
where: {
id: document.id,
},
data: {
deletedAt: null,
},
});
});
await prisma.recipient.updateMany({
where: {
documentId: document.id,
documentDeletedAt: {
not: null,
},
},
data: {
documentDeletedAt: null,
},
});
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_RESTORED,
data: ZWebhookDocumentSchema.parse(mapDocumentToWebhookDocumentPayload(document)),
userId,
teamId,
});
return restoredDocument;
};

View File

@ -39,6 +39,7 @@ export const ZDocumentAuditLogTypeSchema = z.enum([
'DOCUMENT_TITLE_UPDATED', // When the document title is updated.
'DOCUMENT_EXTERNAL_ID_UPDATED', // When the document external ID is updated.
'DOCUMENT_MOVED_TO_TEAM', // When the document is moved to a team.
'DOCUMENT_RESTORED', // When a deleted document is restored.
]);
export const ZDocumentAuditLogEmailTypeSchema = z.enum([
@ -551,6 +552,14 @@ export const ZDocumentAuditLogEventDocumentMovedToTeamSchema = z.object({
}),
});
/**
* Event: Document restored.
*/
export const ZDocumentAuditLogEventDocumentRestoredSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RESTORED),
data: z.object({}),
});
export const ZDocumentAuditLogBaseSchema = z.object({
id: z.string(),
createdAt: z.date(),
@ -588,6 +597,7 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
ZDocumentAuditLogEventRecipientAddedSchema,
ZDocumentAuditLogEventRecipientUpdatedSchema,
ZDocumentAuditLogEventRecipientRemovedSchema,
ZDocumentAuditLogEventDocumentRestoredSchema,
]),
);

View File

@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'DOCUMENT_RESTORED';

View File

@ -178,6 +178,7 @@ enum WebhookTriggerEvents {
DOCUMENT_COMPLETED
DOCUMENT_REJECTED
DOCUMENT_CANCELLED
DOCUMENT_RESTORED
}
model Webhook {

View File

@ -21,6 +21,7 @@ import type { GetStatsInput } from '@documenso/lib/server-only/document/get-stat
import { getStats } from '@documenso/lib/server-only/document/get-stats';
import { moveDocumentToTeam } from '@documenso/lib/server-only/document/move-document-to-team';
import { resendDocument } from '@documenso/lib/server-only/document/resend-document';
import { restoreDocument } from '@documenso/lib/server-only/document/restore-document';
import { searchDocumentsWithKeyword } from '@documenso/lib/server-only/document/search-documents-with-keyword';
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
import { updateDocument } from '@documenso/lib/server-only/document/update-document';
@ -53,6 +54,7 @@ import {
ZMoveDocumentToTeamResponseSchema,
ZMoveDocumentToTeamSchema,
ZResendDocumentMutationSchema,
ZRestoreDocumentMutationSchema,
ZSearchDocumentsMutationSchema,
ZSetSigningOrderForDocumentMutationSchema,
ZSuccessResponseSchema,
@ -415,6 +417,36 @@ export const documentRouter = router({
return ZGenericSuccessResponse;
}),
/**
* @public
*/
restoreDocument: authenticatedProcedure
.meta({
openapi: {
method: 'POST',
path: '/document/restore',
summary: 'Restore deleted document',
tags: ['Document'],
},
})
.input(ZRestoreDocumentMutationSchema)
.output(ZSuccessResponseSchema)
.mutation(async ({ input, ctx }) => {
const { teamId } = ctx;
const { documentId } = input;
const userId = ctx.user.id;
await restoreDocument({
id: documentId,
userId,
teamId,
requestMetadata: ctx.metadata,
});
return ZGenericSuccessResponse;
}),
/**
* @public
*/

View File

@ -349,6 +349,12 @@ export const ZDeleteDocumentMutationSchema = z.object({
export type TDeleteDocumentMutationSchema = z.infer<typeof ZDeleteDocumentMutationSchema>;
export const ZRestoreDocumentMutationSchema = z.object({
documentId: z.number(),
});
export type TRestoreDocumentMutationSchema = z.infer<typeof ZRestoreDocumentMutationSchema>;
export const ZSearchDocumentsMutationSchema = z.object({
query: z.string(),
});