diff --git a/apps/remix/app/components/dialogs/document-restore-dialog.tsx b/apps/remix/app/components/dialogs/document-restore-dialog.tsx new file mode 100644 index 000000000..e5e119f71 --- /dev/null +++ b/apps/remix/app/components/dialogs/document-restore-dialog.tsx @@ -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; + 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 ( + !isPending && onOpenChange(value)}> + + + + Restore Document + + + + {canManageDocument ? ( + + You are about to restore "{documentTitle}" + + ) : ( + + You are about to unhide "{documentTitle}" + + )} + + + + + + {canManageDocument ? ( + + The document will be restored to your account and will be available in your + documents list. + + ) : ( + + The document will be unhidden from your account and will be available in your + documents list. + + )} + + + + + + + + + + + ); +}; diff --git a/apps/remix/app/components/general/document/document-page-view-information.tsx b/apps/remix/app/components/general/document/document-page-view-information.tsx index 6ca4d784c..08ea089ae 100644 --- a/apps/remix/app/components/general/document/document-page-view-information.tsx +++ b/apps/remix/app/components/general/document/document-page-view-information.tsx @@ -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]); diff --git a/apps/remix/app/components/tables/documents-table-action-dropdown.tsx b/apps/remix/app/components/tables/documents-table-action-dropdown.tsx index ea0f11fc1..175b3f793 100644 --- a/apps/remix/app/components/tables/documents-table-action-dropdown.tsx +++ b/apps/remix/app/components/tables/documents-table-action-dropdown.tsx @@ -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 */} - setDeleteDialogOpen(true)}> - - {canManageDocument ? _(msg`Delete`) : _(msg`Hide`)} - + {isDocumentDeleted || (isRecipient && !canManageDocument) ? ( + setRestoreDialogOpen(true)}> + + Restore + + ) : ( + setDeleteDialogOpen(true)}> + + {canManageDocument ? _(msg`Delete`) : _(msg`Hide`)} + + )} Share @@ -220,6 +231,15 @@ export const DocumentsTableActionDropdown = ({ row }: DocumentsTableActionDropdo canManageDocument={canManageDocument} /> + + - (!row.original.deletedAt || isDocumentCompleted(row.original.status)) && ( -
- - -
- ), + cell: ({ row }) => ( +
+ + +
+ ), }, ] satisfies DataTableColumnDef[]; }, [team]); diff --git a/packages/lib/server-only/document/restore-document.ts b/packages/lib/server-only/document/restore-document.ts new file mode 100644 index 000000000..eae319146 --- /dev/null +++ b/packages/lib/server-only/document/restore-document.ts @@ -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; +}; diff --git a/packages/lib/types/document-audit-logs.ts b/packages/lib/types/document-audit-logs.ts index cb7873834..a57464b5b 100644 --- a/packages/lib/types/document-audit-logs.ts +++ b/packages/lib/types/document-audit-logs.ts @@ -39,6 +39,7 @@ export const ZDocumentAuditLogTypeSchema = z.enum([ 'DOCUMENT_TITLE_UPDATED', // When the document title is updated. 'DOCUMENT_EXTERNAL_ID_UPDATED', // When the document external ID is updated. 'DOCUMENT_MOVED_TO_TEAM', // When the document is moved to a team. + 'DOCUMENT_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, ]), ); diff --git a/packages/prisma/migrations/20250313201939_add_document_restored_webhook_event/migration.sql b/packages/prisma/migrations/20250313201939_add_document_restored_webhook_event/migration.sql new file mode 100644 index 000000000..645585f65 --- /dev/null +++ b/packages/prisma/migrations/20250313201939_add_document_restored_webhook_event/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'DOCUMENT_RESTORED'; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index f8eb47897..84692f2c3 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -178,6 +178,7 @@ enum WebhookTriggerEvents { DOCUMENT_COMPLETED DOCUMENT_REJECTED DOCUMENT_CANCELLED + DOCUMENT_RESTORED } model Webhook { diff --git a/packages/trpc/server/document-router/router.ts b/packages/trpc/server/document-router/router.ts index 8ce1b7eb8..078a94f81 100644 --- a/packages/trpc/server/document-router/router.ts +++ b/packages/trpc/server/document-router/router.ts @@ -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 */ diff --git a/packages/trpc/server/document-router/schema.ts b/packages/trpc/server/document-router/schema.ts index f8fb40d47..955aec000 100644 --- a/packages/trpc/server/document-router/schema.ts +++ b/packages/trpc/server/document-router/schema.ts @@ -349,6 +349,12 @@ export const ZDeleteDocumentMutationSchema = z.object({ export type TDeleteDocumentMutationSchema = z.infer; +export const ZRestoreDocumentMutationSchema = z.object({ + documentId: z.number(), +}); + +export type TRestoreDocumentMutationSchema = z.infer; + export const ZSearchDocumentsMutationSchema = z.object({ query: z.string(), });