From 92c09c58505c208bb2cb0970bc429fa9906c35e0 Mon Sep 17 00:00:00 2001
From: Ephraim Duncan <55143799+ephraimduncan@users.noreply.github.com>
Date: Tue, 2 Jul 2024 02:47:24 +0000
Subject: [PATCH] feat: move document to team (#1210)
Introduces a new dialog component allowing users to move documents
between teams with included audit logging.
---
.../documents/data-table-action-dropdown.tsx | 17 +++
.../documents/move-document-dialog.tsx | 117 ++++++++++++++++++
.../document/document-history-sheet.tsx | 2 +-
.../document/find-document-audit-logs.ts | 3 +-
.../document/move-document-to-team.ts | 83 +++++++++++++
packages/lib/types/document-audit-logs.ts | 14 +++
packages/lib/utils/document-audit-logs.ts | 4 +
.../trpc/server/document-router/router.ts | 29 +++++
.../trpc/server/document-router/schema.ts | 5 +
9 files changed, 272 insertions(+), 2 deletions(-)
create mode 100644 apps/web/src/app/(dashboard)/documents/move-document-dialog.tsx
create mode 100644 packages/lib/server-only/document/move-document-to-team.ts
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 aed95662b..2304163b4 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
@@ -12,6 +12,7 @@ import {
EyeIcon,
Loader,
MoreHorizontal,
+ MoveRight,
Pencil,
Share,
Trash2,
@@ -37,6 +38,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
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';
export type DataTableActionDropdownProps = {
row: Document & {
@@ -53,6 +55,7 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
+ const [isMoveDialogOpen, setMoveDialogOpen] = useState(false);
if (!session) {
return null;
@@ -157,6 +160,14 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
Duplicate
+ {/* We don't want to allow teams moving documents across at the moment. */}
+ {!team && (
+ setMoveDialogOpen(true)}>
+
+ Move to Team
+
+ )}
+
{/* No point displaying this if there's no functionality. */}
{/*
@@ -199,6 +210,12 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
canManageDocument={canManageDocument}
/>
+
+
{isDuplicateDialogOpen && (
void;
+};
+
+export const MoveDocumentDialog = ({ documentId, open, onOpenChange }: MoveDocumentDialogProps) => {
+ const router = useRouter();
+ const { toast } = useToast();
+ const [selectedTeamId, setSelectedTeamId] = useState(null);
+
+ const { data: teams, isLoading: isLoadingTeams } = trpc.team.getTeams.useQuery();
+ const { mutateAsync: moveDocument, isLoading } = trpc.document.moveDocumentToTeam.useMutation({
+ onSuccess: () => {
+ router.refresh();
+ toast({
+ title: 'Document moved',
+ description: 'The document has been successfully moved to the selected team.',
+ duration: 5000,
+ });
+ onOpenChange(false);
+ },
+ onError: (error) => {
+ toast({
+ title: 'Error',
+ description: error.message || 'An error occurred while moving the document.',
+ variant: 'destructive',
+ duration: 7500,
+ });
+ },
+ });
+
+ const onMove = async () => {
+ if (!selectedTeamId) return;
+ await moveDocument({ documentId, teamId: selectedTeamId });
+ };
+
+ return (
+
+ );
+};
diff --git a/apps/web/src/components/document/document-history-sheet.tsx b/apps/web/src/components/document/document-history-sheet.tsx
index fa9046ce5..feaae1f77 100644
--- a/apps/web/src/components/document/document-history-sheet.tsx
+++ b/apps/web/src/components/document/document-history-sheet.tsx
@@ -157,6 +157,7 @@ export const DocumentHistorySheet = ({
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED },
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED },
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT },
+ { type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_MOVED_TO_TEAM },
() => null,
)
.with(
@@ -304,7 +305,6 @@ export const DocumentHistorySheet = ({
]}
/>
))
-
.exhaustive()}
{isUserDetailsVisible && (
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 4f423ce8c..87a215f8c 100644
--- a/packages/lib/server-only/document/find-document-audit-logs.ts
+++ b/packages/lib/server-only/document/find-document-audit-logs.ts
@@ -31,7 +31,7 @@ export const findDocumentAuditLogs = async ({
const orderByColumn = orderBy?.column ?? 'createdAt';
const orderByDirection = orderBy?.direction ?? 'desc';
- await prisma.document.findFirstOrThrow({
+ const documentFilter = await prisma.document.findFirstOrThrow({
where: {
id: documentId,
OR: [
@@ -67,6 +67,7 @@ export const findDocumentAuditLogs = async ({
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED,
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT,
+ DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_MOVED_TO_TEAM,
],
},
},
diff --git a/packages/lib/server-only/document/move-document-to-team.ts b/packages/lib/server-only/document/move-document-to-team.ts
new file mode 100644
index 000000000..e034e9ceb
--- /dev/null
+++ b/packages/lib/server-only/document/move-document-to-team.ts
@@ -0,0 +1,83 @@
+import { TRPCError } from '@trpc/server';
+
+import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
+import { prisma } from '@documenso/prisma';
+
+import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
+import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
+
+export type MoveDocumentToTeamOptions = {
+ documentId: number;
+ teamId: number;
+ userId: number;
+ requestMetadata?: RequestMetadata;
+};
+
+export const moveDocumentToTeam = async ({
+ documentId,
+ teamId,
+ userId,
+ requestMetadata,
+}: MoveDocumentToTeamOptions) => {
+ return await prisma.$transaction(async (tx) => {
+ const user = await tx.user.findUniqueOrThrow({
+ where: { id: userId },
+ });
+
+ const document = await tx.document.findFirst({
+ where: {
+ id: documentId,
+ userId,
+ teamId: null,
+ },
+ });
+
+ if (!document) {
+ throw new TRPCError({
+ code: 'NOT_FOUND',
+ message: 'Document not found or already associated with a team.',
+ });
+ }
+
+ const team = await tx.team.findFirst({
+ where: {
+ id: teamId,
+ members: {
+ some: {
+ userId,
+ },
+ },
+ },
+ });
+
+ if (!team) {
+ throw new TRPCError({
+ code: 'FORBIDDEN',
+ message: 'You are not a member of this team.',
+ });
+ }
+
+ const updatedDocument = await tx.document.update({
+ where: { id: documentId },
+ data: { teamId },
+ });
+
+ const log = await tx.documentAuditLog.create({
+ data: createDocumentAuditLogData({
+ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_MOVED_TO_TEAM,
+ documentId: updatedDocument.id,
+ user,
+ requestMetadata,
+ data: {
+ movedByUserId: userId,
+ fromPersonalAccount: true,
+ toTeamId: teamId,
+ },
+ }),
+ });
+
+ console.log(log);
+
+ return updatedDocument;
+ });
+};
diff --git a/packages/lib/types/document-audit-logs.ts b/packages/lib/types/document-audit-logs.ts
index 602396b3a..73bb3b9c9 100644
--- a/packages/lib/types/document-audit-logs.ts
+++ b/packages/lib/types/document-audit-logs.ts
@@ -35,6 +35,7 @@ export const ZDocumentAuditLogTypeSchema = z.enum([
'DOCUMENT_RECIPIENT_COMPLETED', // When a recipient completes all their required tasks for the document.
'DOCUMENT_SENT', // When the document transitions from DRAFT to PENDING.
'DOCUMENT_TITLE_UPDATED', // When the document title is updated.
+ 'DOCUMENT_MOVED_TO_TEAM', // When the document is moved to a team.
]);
export const ZDocumentAuditLogEmailTypeSchema = z.enum([
@@ -410,6 +411,18 @@ export const ZDocumentAuditLogEventRecipientRemovedSchema = z.object({
data: ZBaseRecipientDataSchema,
});
+/**
+ * Event: Document moved to team.
+ */
+export const ZDocumentAuditLogEventDocumentMovedToTeamSchema = z.object({
+ type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_MOVED_TO_TEAM),
+ data: z.object({
+ movedByUserId: z.number(),
+ fromPersonalAccount: z.boolean(),
+ toTeamId: z.number(),
+ }),
+});
+
export const ZDocumentAuditLogBaseSchema = z.object({
id: z.string(),
createdAt: z.date(),
@@ -427,6 +440,7 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
ZDocumentAuditLogEventDocumentCompletedSchema,
ZDocumentAuditLogEventDocumentCreatedSchema,
ZDocumentAuditLogEventDocumentDeletedSchema,
+ ZDocumentAuditLogEventDocumentMovedToTeamSchema,
ZDocumentAuditLogEventDocumentFieldInsertedSchema,
ZDocumentAuditLogEventDocumentFieldUninsertedSchema,
ZDocumentAuditLogEventDocumentGlobalAuthAccessUpdatedSchema,
diff --git a/packages/lib/utils/document-audit-logs.ts b/packages/lib/utils/document-audit-logs.ts
index 97ef38c8b..83efe04d9 100644
--- a/packages/lib/utils/document-audit-logs.ts
+++ b/packages/lib/utils/document-audit-logs.ts
@@ -336,6 +336,10 @@ export const formatDocumentAuditLogAction = (auditLog: TDocumentAuditLog, userId
anonymous: 'Document sent',
identified: 'sent the document',
}))
+ .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_MOVED_TO_TEAM }, () => ({
+ anonymous: 'Document moved to team',
+ identified: 'moved the document to team',
+ }))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED }, ({ data }) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const action = RECIPIENT_ROLES_DESCRIPTION[data.recipientRole as RecipientRole]?.actioned;
diff --git a/packages/trpc/server/document-router/router.ts b/packages/trpc/server/document-router/router.ts
index 64f3c2480..07726b451 100644
--- a/packages/trpc/server/document-router/router.ts
+++ b/packages/trpc/server/document-router/router.ts
@@ -14,6 +14,7 @@ import { findDocumentAuditLogs } from '@documenso/lib/server-only/document/find-
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
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 { searchDocumentsWithKeyword } from '@documenso/lib/server-only/document/search-documents-with-keyword';
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
@@ -32,6 +33,7 @@ import {
ZGetDocumentByIdQuerySchema,
ZGetDocumentByTokenQuerySchema,
ZGetDocumentWithDetailsByIdQuerySchema,
+ ZMoveDocumentsToTeamSchema,
ZResendDocumentMutationSchema,
ZSearchDocumentsMutationSchema,
ZSendDocumentMutationSchema,
@@ -158,6 +160,33 @@ export const documentRouter = router({
}
}),
+ moveDocumentToTeam: authenticatedProcedure
+ .input(ZMoveDocumentsToTeamSchema)
+ .mutation(async ({ input, ctx }) => {
+ try {
+ const { documentId, teamId } = input;
+ const userId = ctx.user.id;
+
+ return await moveDocumentToTeam({
+ documentId,
+ teamId,
+ userId,
+ requestMetadata: extractNextApiRequestMetadata(ctx.req),
+ });
+ } catch (err) {
+ console.error(err);
+
+ if (err instanceof TRPCError) {
+ throw err;
+ }
+
+ throw new TRPCError({
+ code: 'BAD_REQUEST',
+ message: 'We were unable to move 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 483d32e50..33b2a92c5 100644
--- a/packages/trpc/server/document-router/schema.ts
+++ b/packages/trpc/server/document-router/schema.ts
@@ -168,3 +168,8 @@ export const ZDownloadAuditLogsMutationSchema = z.object({
documentId: z.number(),
teamId: z.number().optional(),
});
+
+export const ZMoveDocumentsToTeamSchema = z.object({
+ documentId: z.number(),
+ teamId: z.number(),
+});