diff --git a/apps/web/src/app/(dashboard)/admin/documents/[id]/page.tsx b/apps/web/src/app/(dashboard)/admin/documents/[id]/page.tsx index a22345457..0682e6c5e 100644 --- a/apps/web/src/app/(dashboard)/admin/documents/[id]/page.tsx +++ b/apps/web/src/app/(dashboard)/admin/documents/[id]/page.tsx @@ -14,6 +14,7 @@ import { LocaleDate } from '~/components/formatter/locale-date'; import { AdminActions } from './admin-actions'; import { RecipientItem } from './recipient-item'; +import { SuperDeleteDocumentDialog } from './super-delete-document-dialog'; type AdminDocumentDetailsPageProps = { params: { @@ -81,6 +82,10 @@ export default async function AdminDocumentDetailsPage({ params }: AdminDocument ))} + +
+ + {document && } ); } diff --git a/apps/web/src/app/(dashboard)/admin/documents/[id]/super-delete-document-dialog.tsx b/apps/web/src/app/(dashboard)/admin/documents/[id]/super-delete-document-dialog.tsx new file mode 100644 index 000000000..63ad88a3f --- /dev/null +++ b/apps/web/src/app/(dashboard)/admin/documents/[id]/super-delete-document-dialog.tsx @@ -0,0 +1,130 @@ +'use client'; + +import { useState } from 'react'; + +import { useRouter } from 'next/navigation'; + +import type { Document } from '@documenso/prisma/client'; +import { TRPCClientError } from '@documenso/trpc/client'; +import { trpc } from '@documenso/trpc/react'; +import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { Input } from '@documenso/ui/primitives/input'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type SuperDeleteDocumentDialogProps = { + document: Document; +}; + +export const SuperDeleteDocumentDialog = ({ document }: SuperDeleteDocumentDialogProps) => { + const { toast } = useToast(); + const router = useRouter(); + + const [reason, setReason] = useState(''); + + const { mutateAsync: deleteDocument, isLoading: isDeletingDocument } = + trpc.admin.deleteDocument.useMutation(); + + const handleDeleteDocument = async () => { + try { + if (!reason) { + return; + } + + await deleteDocument({ id: document.id, reason }); + + toast({ + title: 'Document deleted', + description: 'The Document has been deleted successfully.', + duration: 5000, + }); + + router.push('/admin/documents'); + } catch (err) { + if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') { + toast({ + title: 'An error occurred', + description: err.message, + variant: 'destructive', + }); + } else { + toast({ + title: 'An unknown error occurred', + variant: 'destructive', + description: + err.message ?? + 'We encountered an unknown error while attempting to delete your document. Please try again later.', + }); + } + } + }; + + return ( +
+
+ +
+ Delete Document + + Delete the document. This action is irreversible so proceed with caution. + +
+ +
+ + + + + + + + Delete Document + + + + This action is not reversible. Please be certain. + + + + +
+ To confirm, please enter the reason + + setReason(e.target.value)} + /> +
+ + + + +
+
+
+
+
+
+ ); +}; diff --git a/packages/email/template-components/template-document-super-delete.tsx b/packages/email/template-components/template-document-super-delete.tsx new file mode 100644 index 000000000..9cb0a9e71 --- /dev/null +++ b/packages/email/template-components/template-document-super-delete.tsx @@ -0,0 +1,45 @@ +import { Section, Text } from '../components'; +import { TemplateDocumentImage } from './template-document-image'; + +export interface TemplateDocumentDeleteProps { + reason: string; + documentName: string; + assetBaseUrl: string; +} + +export const TemplateDocumentDelete = ({ + reason, + documentName, + assetBaseUrl, +}: TemplateDocumentDeleteProps) => { + return ( + <> + + +
+ + Your document has been deleted by an admin! + + + + "{documentName}" has been deleted by an admin. + + + + This document can not be recovered, if you would like to dispute the reason for future + documents please contact support. + + + + The reason provided for deletion is the following: + + + + {reason} + +
+ + ); +}; + +export default TemplateDocumentDelete; diff --git a/packages/email/templates/document-super-delete.tsx b/packages/email/templates/document-super-delete.tsx new file mode 100644 index 000000000..68384e119 --- /dev/null +++ b/packages/email/templates/document-super-delete.tsx @@ -0,0 +1,66 @@ +import config from '@documenso/tailwind-config'; + +import { Body, Container, Head, Hr, Html, Img, Preview, Section, Tailwind } from '../components'; +import { + TemplateDocumentDelete, + type TemplateDocumentDeleteProps, +} from '../template-components/template-document-super-delete'; +import { TemplateFooter } from '../template-components/template-footer'; + +export type DocumentDeleteEmailTemplateProps = Partial; + +export const DocumentSuperDeleteEmailTemplate = ({ + documentName = 'Open Source Pledge.pdf', + assetBaseUrl = 'http://localhost:3002', + reason = 'Unknown', +}: DocumentDeleteEmailTemplateProps) => { + const previewText = `An admin has deleted your document "${documentName}".`; + + const getAssetUrl = (path: string) => { + return new URL(path, assetBaseUrl).toString(); + }; + + return ( + + + {previewText} + + +
+ +
+ Documenso Logo + + +
+
+ +
+ + + + +
+ +
+ + ); +}; + +export default DocumentSuperDeleteEmailTemplate; diff --git a/packages/lib/server-only/document/send-delete-email.ts b/packages/lib/server-only/document/send-delete-email.ts new file mode 100644 index 000000000..cc1101942 --- /dev/null +++ b/packages/lib/server-only/document/send-delete-email.ts @@ -0,0 +1,52 @@ +import { createElement } from 'react'; + +import { mailer } from '@documenso/email/mailer'; +import { render } from '@documenso/email/render'; +import { DocumentSuperDeleteEmailTemplate } from '@documenso/email/templates/document-super-delete'; +import { prisma } from '@documenso/prisma'; + +import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; + +export interface SendDeleteEmailOptions { + documentId: number; + reason: string; +} + +export const sendDeleteEmail = async ({ documentId, reason }: SendDeleteEmailOptions) => { + const document = await prisma.document.findFirst({ + where: { + id: documentId, + }, + include: { + User: true, + }, + }); + + if (!document) { + throw new Error('Document not found'); + } + + const { email, name } = document.User; + + const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000'; + + const template = createElement(DocumentSuperDeleteEmailTemplate, { + documentName: document.title, + reason, + assetBaseUrl, + }); + + await mailer.sendMail({ + to: { + address: email, + name: name || '', + }, + from: { + name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso', + address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com', + }, + subject: 'Document Deleted!', + html: render(template), + text: render(template, { plainText: true }), + }); +}; diff --git a/packages/lib/server-only/document/super-delete-document.ts b/packages/lib/server-only/document/super-delete-document.ts new file mode 100644 index 000000000..0a5ed69c0 --- /dev/null +++ b/packages/lib/server-only/document/super-delete-document.ts @@ -0,0 +1,85 @@ +'use server'; + +import { createElement } from 'react'; + +import { mailer } from '@documenso/email/mailer'; +import { render } from '@documenso/email/render'; +import DocumentCancelTemplate from '@documenso/email/templates/document-cancel'; +import { prisma } from '@documenso/prisma'; +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 SuperDeleteDocumentOptions = { + id: number; + requestMetadata?: RequestMetadata; +}; + +export const superDeleteDocument = async ({ id, requestMetadata }: SuperDeleteDocumentOptions) => { + const document = await prisma.document.findUnique({ + where: { + id, + }, + include: { + Recipient: true, + documentMeta: true, + User: true, + }, + }); + + if (!document) { + throw new Error('Document not found'); + } + + const { status, User: user } = document; + + // if the document is pending, send cancellation emails to all recipients + if (status === DocumentStatus.PENDING && document.Recipient.length > 0) { + await Promise.all( + document.Recipient.map(async (recipient) => { + const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000'; + const template = createElement(DocumentCancelTemplate, { + 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 Cancelled', + html: render(template), + text: render(template, { plainText: true }), + }); + }), + ); + } + + // always hard delete if deleted from admin + return await prisma.$transaction(async (tx) => { + await tx.documentAuditLog.create({ + data: createDocumentAuditLogData({ + documentId: id, + type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED, + user, + requestMetadata, + data: { + type: 'HARD', + }, + }), + }); + + return await tx.document.delete({ where: { id } }); + }); +}; diff --git a/packages/trpc/server/admin-router/router.ts b/packages/trpc/server/admin-router/router.ts index 5be3ad9db..b37510be7 100644 --- a/packages/trpc/server/admin-router/router.ts +++ b/packages/trpc/server/admin-router/router.ts @@ -4,12 +4,16 @@ import { findDocuments } from '@documenso/lib/server-only/admin/get-all-document import { updateRecipient } from '@documenso/lib/server-only/admin/update-recipient'; import { updateUser } from '@documenso/lib/server-only/admin/update-user'; import { sealDocument } from '@documenso/lib/server-only/document/seal-document'; +import { sendDeleteEmail } from '@documenso/lib/server-only/document/send-delete-email'; +import { superDeleteDocument } from '@documenso/lib/server-only/document/super-delete-document'; import { upsertSiteSetting } from '@documenso/lib/server-only/site-settings/upsert-site-setting'; import { deleteUser } from '@documenso/lib/server-only/user/delete-user'; import { getUserById } from '@documenso/lib/server-only/user/get-user-by-id'; +import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { adminProcedure, router } from '../trpc'; import { + ZAdminDeleteDocumentMutationSchema, ZAdminDeleteUserMutationSchema, ZAdminFindDocumentsQuerySchema, ZAdminResealDocumentMutationSchema, @@ -118,4 +122,25 @@ export const adminRouter = router({ }); } }), + + deleteDocument: adminProcedure + .input(ZAdminDeleteDocumentMutationSchema) + .mutation(async ({ ctx, input }) => { + const { id, reason } = input; + try { + await sendDeleteEmail({ documentId: id, reason }); + + return await superDeleteDocument({ + id, + requestMetadata: extractNextApiRequestMetadata(ctx.req), + }); + } catch (err) { + console.log(err); + + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'We were unable to delete the specified document. Please try again.', + }); + } + }), }); diff --git a/packages/trpc/server/admin-router/schema.ts b/packages/trpc/server/admin-router/schema.ts index cfedb06ba..6bb567dbd 100644 --- a/packages/trpc/server/admin-router/schema.ts +++ b/packages/trpc/server/admin-router/schema.ts @@ -48,3 +48,10 @@ export const ZAdminDeleteUserMutationSchema = z.object({ }); export type TAdminDeleteUserMutationSchema = z.infer; + +export const ZAdminDeleteDocumentMutationSchema = z.object({ + id: z.number().min(1), + reason: z.string(), +}); + +export type TAdminDeleteDocomentMutationSchema = z.infer;