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.
+
+
+
+
+
+
+
+
+
+ );
+};
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}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+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;