diff --git a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-information.tsx b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-information.tsx index 24a85bacc..5d6f8af3a 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-information.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-information.tsx @@ -26,6 +26,9 @@ export const DocumentPageViewInformation = ({ const documentInformation = useMemo(() => { let createdValue = DateTime.fromJSDate(document.createdAt).toFormat('MMMM d, yyyy'); + let deletedValue = + document.deletedAt && DateTime.fromJSDate(document.deletedAt).toFormat('MMMM d, yyyy'); + let lastModifiedValue = DateTime.fromJSDate(document.updatedAt).toRelative(); if (!isMounted) { @@ -34,9 +37,13 @@ export const DocumentPageViewInformation = ({ .toFormat('MMMM d, yyyy'); lastModifiedValue = DateTime.fromJSDate(document.updatedAt).setLocale(locale).toRelative(); + + deletedValue = + document.deletedAt && + DateTime.fromJSDate(document.deletedAt).setLocale(locale).toFormat('MMMM d, yyyy'); } - return [ + const info = [ { description: 'Uploaded by', value: userId === document.userId ? 'You' : document.User.name ?? document.User.email, @@ -50,6 +57,15 @@ export const DocumentPageViewInformation = ({ value: lastModifiedValue, }, ]; + + if (deletedValue) { + info.push({ + description: 'Deleted', + value: deletedValue, + }); + } + + return info; }, [isMounted, document, locale, userId]); return ( diff --git a/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx b/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx index 2304163b4..a6bb7975c 100644 --- a/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx +++ b/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx @@ -5,6 +5,7 @@ import { useState } from 'react'; import Link from 'next/link'; import { + ArchiveRestore, CheckCircle, Copy, Download, @@ -39,6 +40,7 @@ import { ResendDocumentActionItem } from './_action-items/resend-document'; import { DeleteDocumentDialog } from './delete-document-dialog'; import { DuplicateDocumentDialog } from './duplicate-document-dialog'; import { MoveDocumentDialog } from './move-document-dialog'; +import { RestoreDocumentDialog } from './restore-document-dialog'; export type DataTableActionDropdownProps = { row: Document & { @@ -56,6 +58,7 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false); const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false); const [isMoveDialogOpen, setMoveDialogOpen] = useState(false); + const [isRestoreDialogOpen, setRestoreDialogOpen] = useState(false); if (!session) { return null; @@ -71,6 +74,7 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr // const isSigned = recipient?.signingStatus === SigningStatus.SIGNED; const isCurrentTeamDocument = team && row.team?.url === team.url; const canManageDocument = Boolean(isOwner || isCurrentTeamDocument); + const isDeletedDocument = row.deletedAt !== null; const documentsPath = formatDocumentsPath(team?.url); @@ -174,13 +178,23 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr Void */} - setDeleteDialogOpen(true)} - disabled={Boolean(!canManageDocument && team?.teamEmail)} - > - - {canManageDocument ? 'Delete' : 'Hide'} - + {isDeletedDocument ? ( + setRestoreDialogOpen(true)} + disabled={Boolean(!canManageDocument)} + > + + Restore + + ) : ( + setDeleteDialogOpen(true)} + disabled={Boolean(!canManageDocument && team?.teamEmail)} + > + + {canManageDocument ? 'Delete' : 'Hide'} + + )} Share @@ -216,6 +230,16 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr onOpenChange={setMoveDialogOpen} /> + + {isDuplicateDialogOpen && ( void; + status: DocumentStatus; + documentTitle: string; + teamId?: number; + canManageDocument: boolean; +}; + +export function RestoreDocumentDialog({ + id, + teamId, + open, + onOpenChange, + documentTitle, + canManageDocument, +}: RestoreDocumentDialogProps) { + const router = useRouter(); + const { toast } = useToast(); + + const { mutateAsync: restoreDocument, isLoading } = + trpcReact.document.restoreDocument.useMutation({ + onSuccess: () => { + router.refresh(); + + toast({ + title: 'Document restored', + description: `"${documentTitle}" has been successfully restored`, + duration: 5000, + }); + + onOpenChange(false); + }, + }); + + const onRestore = async () => { + try { + await restoreDocument({ id, teamId }); + } catch { + toast({ + title: 'Something went wrong', + description: 'This document could not be restored at this time. Please try again.', + variant: 'destructive', + duration: 7500, + }); + } + }; + + return ( + !isLoading && onOpenChange(value)}> + + + Are you absolutely sure? + + You are about to restore the document "{documentTitle}" + + + + Cancel + + + + + + ); +} diff --git a/apps/web/src/components/document/document-history-sheet.tsx b/apps/web/src/components/document/document-history-sheet.tsx index 25fd798d5..2b5ec9bf4 100644 --- a/apps/web/src/components/document/document-history-sheet.tsx +++ b/apps/web/src/components/document/document-history-sheet.tsx @@ -154,6 +154,7 @@ export const DocumentHistorySheet = ({ { 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_RESTORED }, { type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED }, { type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED }, { type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT }, diff --git a/packages/email/templates/document-restore.tsx b/packages/email/templates/document-restore.tsx new file mode 100644 index 000000000..428eb34b9 --- /dev/null +++ b/packages/email/templates/document-restore.tsx @@ -0,0 +1,67 @@ +import config from '@documenso/tailwind-config'; + +import { Body, Container, Head, Hr, Html, Img, Preview, Section, Tailwind } from '../components'; +import type { TemplateDocumentCancelProps } from '../template-components/template-document-cancel'; +import { TemplateDocumentCancel } from '../template-components/template-document-cancel'; +import { TemplateFooter } from '../template-components/template-footer'; + +export type DocumentCancelEmailTemplateProps = Partial; + +// TODO: Finish this +export const DocumentRestoreTemplate = ({ + inviterName = 'Lucas Smith', + inviterEmail = 'lucas@documenso.com', + documentName = 'Open Source Pledge.pdf', + assetBaseUrl = 'http://localhost:3002', +}: DocumentCancelEmailTemplateProps) => { + const previewText = `${inviterName} has cancelled the document ${documentName}, you don't need to sign it anymore.`; + + const getAssetUrl = (path: string) => { + return new URL(path, assetBaseUrl).toString(); + }; + + return ( + + + {previewText} + + +
+ +
+ Documenso Logo + + +
+
+ +
+ + + + +
+ +
+ + ); +}; + +export default DocumentRestoreTemplate; diff --git a/packages/lib/server-only/document/find-document-audit-logs.ts b/packages/lib/server-only/document/find-document-audit-logs.ts index 87a215f8c..f1ae99947 100644 --- a/packages/lib/server-only/document/find-document-audit-logs.ts +++ b/packages/lib/server-only/document/find-document-audit-logs.ts @@ -64,6 +64,7 @@ export const findDocumentAuditLogs = async ({ DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED, DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED, DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED, + DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RESTORED, DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED, DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED, DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT, 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..769ab08a0 --- /dev/null +++ b/packages/lib/server-only/document/restore-document.ts @@ -0,0 +1,204 @@ +'use server'; + +import { createElement } from 'react'; + +import { mailer } from '@documenso/email/mailer'; +import { render } from '@documenso/email/render'; +import DocumentRestoreTemplate from '@documenso/email/templates/document-restore'; +import { prisma } from '@documenso/prisma'; +import type { Document, DocumentMeta, Recipient, User } from '@documenso/prisma/client'; +import { DocumentStatus } from '@documenso/prisma/client'; + +import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; +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 RestoreDocumentOptions = { + id: number; + userId: number; + teamId?: number; + requestMetadata?: RequestMetadata; +}; + +export const restoreDocument = async ({ + id, + userId, + teamId, + requestMetadata, +}: RestoreDocumentOptions) => { + const user = await prisma.user.findUnique({ + where: { + id: userId, + }, + }); + + if (!user) { + throw new Error('User not found'); + } + + const document = await prisma.document.findUnique({ + where: { + id, + }, + include: { + Recipient: true, + documentMeta: true, + team: { + select: { + members: true, + }, + }, + }, + }); + + if (!document || (teamId !== undefined && teamId !== document.teamId)) { + throw new Error('Document not found'); + } + + const isUserOwner = document.userId === userId; + const isUserTeamMember = document.team?.members.some((member) => member.userId === userId); + const userRecipient = document.Recipient.find((recipient) => recipient.email === user.email); + + if (!isUserOwner && !isUserTeamMember && !userRecipient) { + throw new Error('Not allowed'); + } + + // Handle restoring the actual document if user has permission. + if (isUserOwner || isUserTeamMember) { + await handleDocumentOwnerRestore({ + document, + user, + requestMetadata, + }); + } + + // Continue to show the document to the user if they are a recipient. + if (userRecipient?.documentDeletedAt !== null) { + await prisma.recipient + .update({ + where: { + id: userRecipient?.id, + }, + data: { + documentDeletedAt: null, + }, + }) + .catch(() => { + // Do nothing. + }); + } + + // Return partial document for API v1 response. + return { + id: document.id, + userId: document.userId, + teamId: document.teamId, + title: document.title, + status: document.status, + documentDataId: document.documentDataId, + createdAt: document.createdAt, + updatedAt: document.updatedAt, + completedAt: document.completedAt, + }; +}; + +type HandleDocumentOwnerRestoreOptions = { + document: Document & { + Recipient: Recipient[]; + documentMeta: DocumentMeta | null; + }; + user: User; + requestMetadata?: RequestMetadata; +}; + +const handleDocumentOwnerRestore = async ({ + document, + user, + requestMetadata, +}: HandleDocumentOwnerRestoreOptions) => { + if (!document.deletedAt) { + return; + } + + // Restore soft-deleted documents. + if (document.status === DocumentStatus.COMPLETED) { + return await prisma.$transaction(async (tx) => { + await tx.documentAuditLog.create({ + data: createDocumentAuditLogData({ + documentId: document.id, + type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RESTORED, + user, + requestMetadata, + data: { + type: 'RESTORE', + }, + }), + }); + + return await tx.document.update({ + where: { + id: document.id, + }, + data: { + deletedAt: null, + }, + }); + }); + } + + // Restore draft and pending documents. + const restoredDocument = await prisma.$transaction(async (tx) => { + await tx.documentAuditLog.create({ + data: createDocumentAuditLogData({ + documentId: document.id, + type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RESTORED, + user, + requestMetadata, + data: { + type: 'RESTORE', + }, + }), + }); + + return await tx.document.update({ + where: { + id: document.id, + }, + data: { + deletedAt: null, + }, + }); + }); + + // Send restoration emails to recipients. + await Promise.all( + document.Recipient.map(async (recipient) => { + const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000'; + + const template = createElement(DocumentRestoreTemplate, { + documentName: document.title, + inviterName: user.name || undefined, + inviterEmail: user.email, + assetBaseUrl, + }); + + await mailer.sendMail({ + to: { + address: recipient.email, + name: recipient.name, + }, + from: { + name: FROM_NAME, + address: FROM_ADDRESS, + }, + subject: 'Document Restored', + html: render(template), + text: render(template, { plainText: true }), + }); + }), + ); + + return restoredDocument; +}; diff --git a/packages/lib/types/document-audit-logs.ts b/packages/lib/types/document-audit-logs.ts index 1d2bf7c53..678438299 100644 --- a/packages/lib/types/document-audit-logs.ts +++ b/packages/lib/types/document-audit-logs.ts @@ -26,6 +26,7 @@ export const ZDocumentAuditLogTypeSchema = z.enum([ '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_RESTORED', // When the document is restored. '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_GLOBAL_AUTH_ACCESS_UPDATED', // When the global access authentication is updated. @@ -223,6 +224,16 @@ export const ZDocumentAuditLogEventDocumentDeletedSchema = z.object({ }), }); +/** + * Event: Document restored. + */ +export const ZDocumentAuditLogEventDocumentRestoredSchema = z.object({ + type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RESTORED), + data: z.object({ + type: z.enum(['RESTORE']), + }), +}); + /** * Event: Document field inserted. */ @@ -469,6 +480,7 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and( ZDocumentAuditLogEventDocumentCreatedSchema, ZDocumentAuditLogEventDocumentDeletedSchema, ZDocumentAuditLogEventDocumentMovedToTeamSchema, + ZDocumentAuditLogEventDocumentRestoredSchema, ZDocumentAuditLogEventDocumentFieldInsertedSchema, ZDocumentAuditLogEventDocumentFieldUninsertedSchema, ZDocumentAuditLogEventDocumentGlobalAuthAccessUpdatedSchema, diff --git a/packages/lib/utils/document-audit-logs.ts b/packages/lib/utils/document-audit-logs.ts index 5b0215a2f..2213faf57 100644 --- a/packages/lib/utils/document-audit-logs.ts +++ b/packages/lib/utils/document-audit-logs.ts @@ -304,6 +304,10 @@ export const formatDocumentAuditLogAction = (auditLog: TDocumentAuditLog, userId anonymous: 'Document deleted', identified: 'deleted the document', })) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RESTORED }, () => ({ + anonymous: 'Document restored', + identified: 'restored the document', + })) .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED }, () => ({ anonymous: 'Field signed', identified: 'signed a field', diff --git a/packages/trpc/server/document-router/router.ts b/packages/trpc/server/document-router/router.ts index 07726b451..b2e07d5cf 100644 --- a/packages/trpc/server/document-router/router.ts +++ b/packages/trpc/server/document-router/router.ts @@ -16,6 +16,7 @@ import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id'; 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 { updateDocumentSettings } from '@documenso/lib/server-only/document/update-document-settings'; @@ -35,6 +36,7 @@ import { ZGetDocumentWithDetailsByIdQuerySchema, ZMoveDocumentsToTeamSchema, ZResendDocumentMutationSchema, + ZRestoreDocumentMutationSchema, ZSearchDocumentsMutationSchema, ZSendDocumentMutationSchema, ZSetPasswordForDocumentMutationSchema, @@ -187,6 +189,34 @@ export const documentRouter = router({ } }), + restoreDocument: authenticatedProcedure + .input(ZRestoreDocumentMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + const { id, teamId } = input; + + const userId = ctx.user.id; + + const restoredDocument = await restoreDocument({ + id, + userId, + teamId, + requestMetadata: extractNextApiRequestMetadata(ctx.req), + }); + + console.log(restoredDocument); + + return restoredDocument; + } catch (err) { + console.error(err); + + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'We were unable to restore this document. Please try again later.', + }); + } + }), + findDocumentAuditLogs: authenticatedProcedure .input(ZFindDocumentAuditLogsQuerySchema) .query(async ({ input, ctx }) => { diff --git a/packages/trpc/server/document-router/schema.ts b/packages/trpc/server/document-router/schema.ts index ac278b1d8..56af4644e 100644 --- a/packages/trpc/server/document-router/schema.ts +++ b/packages/trpc/server/document-router/schema.ts @@ -159,6 +159,11 @@ export const ZDeleteDraftDocumentMutationSchema = z.object({ teamId: z.number().min(1).optional(), }); +export const ZRestoreDocumentMutationSchema = z.object({ + id: z.number().min(1), + teamId: z.number().min(1).optional(), +}); + export type TDeleteDraftDocumentMutationSchema = z.infer; export const ZSearchDocumentsMutationSchema = z.object({