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 ( + + + + Move Document to Team + + Select a team to move this document to. This action cannot be undone. + + + + + + + + + + + + ); +}; 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(), +});