diff --git a/apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx b/apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx new file mode 100644 index 000000000..47820314d --- /dev/null +++ b/apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx @@ -0,0 +1,160 @@ +import { Plural, useLingui } from '@lingui/react/macro'; +import { Trans } from '@lingui/react/macro'; +import { EnvelopeType } from '@prisma/client'; +import type * as DialogPrimitive from '@radix-ui/react-dialog'; + +import { trpc } from '@documenso/trpc/react'; +import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@documenso/ui/primitives/dialog'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type EnvelopesBulkDeleteDialogProps = { + envelopeIds: string[]; + envelopeType: EnvelopeType; + open: boolean; + onOpenChange: (open: boolean) => void; + onSuccess?: () => void; +} & Omit; + +export const EnvelopesBulkDeleteDialog = ({ + envelopeIds, + envelopeType, + open, + onOpenChange, + onSuccess, + ...props +}: EnvelopesBulkDeleteDialogProps) => { + const { t } = useLingui(); + const { toast } = useToast(); + + const trpcUtils = trpc.useUtils(); + + const isDocument = envelopeType === EnvelopeType.DOCUMENT; + + const { mutateAsync: bulkDeleteEnvelopes, isPending } = trpc.envelope.bulk.delete.useMutation({ + onSuccess: async (result) => { + // Invalidate the appropriate query based on envelope type. + if (isDocument) { + await trpcUtils.document.findDocumentsInternal.invalidate(); + } else { + await trpcUtils.template.findTemplates.invalidate(); + } + + if (result.failedIds.length > 0) { + toast({ + title: isDocument ? t`Documents partially deleted` : t`Templates partially deleted`, + description: t`${result.deletedCount} item(s) deleted. ${result.failedIds.length} item(s) could not be deleted.`, + variant: 'destructive', + }); + } else { + toast({ + title: isDocument ? t`Documents deleted` : t`Templates deleted`, + description: t`${result.deletedCount} item(s) have been deleted.`, + variant: 'default', + }); + } + + onSuccess?.(); + onOpenChange(false); + }, + onError: () => { + toast({ + title: t`Error`, + description: t`An error occurred while deleting the items.`, + variant: 'destructive', + }); + }, + }); + + return ( + + + + + {isDocument ? Delete Documents : Delete Templates} + + + + {isDocument ? ( + + ) : ( + + )} + + + + + +

+ + Please note that this action is irreversible. + +

+ +

+ Once confirmed, the following will occur: +

+ +
    + {isDocument ? ( + <> +
  • + Selected documents will be permanently deleted +
  • +
  • + Pending documents will have their signing process cancelled +
  • +
  • + All recipients will be notified +
  • + + ) : ( + <> +
  • + Selected templates will be permanently deleted +
  • +
  • + Direct links associated with templates will be removed +
  • + + )} +
+
+
+ + + + + + +
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx b/apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx new file mode 100644 index 000000000..be50e85a2 --- /dev/null +++ b/apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx @@ -0,0 +1,256 @@ +import { useEffect, useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { Plural, useLingui } from '@lingui/react/macro'; +import { Trans } from '@lingui/react/macro'; +import { EnvelopeType } from '@prisma/client'; +import type * as DialogPrimitive from '@radix-ui/react-dialog'; +import { FolderIcon, HomeIcon, Loader2, Search } from 'lucide-react'; +import { useForm } from 'react-hook-form'; +import { match } from 'ts-pattern'; +import { z } from 'zod'; + +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@documenso/ui/primitives/dialog'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type EnvelopesBulkMoveDialogProps = { + envelopeIds: string[]; + envelopeType: EnvelopeType; + open: boolean; + onOpenChange: (open: boolean) => void; + currentFolderId?: string; + onSuccess?: () => void; +} & Omit; + +const ZBulkMoveFormSchema = z.object({ + folderId: z.string().nullable(), +}); + +type TBulkMoveFormSchema = z.infer; + +export const EnvelopesBulkMoveDialog = ({ + envelopeIds, + envelopeType, + open, + onOpenChange, + currentFolderId, + onSuccess, + ...props +}: EnvelopesBulkMoveDialogProps) => { + const { t } = useLingui(); + const { toast } = useToast(); + + const [searchTerm, setSearchTerm] = useState(''); + + const form = useForm({ + resolver: zodResolver(ZBulkMoveFormSchema), + defaultValues: { + folderId: currentFolderId ?? null, + }, + }); + + const isDocument = envelopeType === EnvelopeType.DOCUMENT; + + const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFoldersInternal.useQuery( + { + parentId: currentFolderId, + type: envelopeType, + }, + { + enabled: open, + }, + ); + + const { mutateAsync: bulkMoveEnvelopes } = trpc.envelope.bulk.move.useMutation(); + + const trpcUtils = trpc.useUtils(); + + useEffect(() => { + if (open) { + setSearchTerm(''); + + form.reset({ + folderId: currentFolderId, + }); + } + }, [open, currentFolderId]); + + const onSubmit = async (data: TBulkMoveFormSchema) => { + try { + await bulkMoveEnvelopes({ + envelopeIds, + folderId: data.folderId, + envelopeType, + }); + + // Invalidate the appropriate query based on envelope type. + if (isDocument) { + await trpcUtils.document.findDocumentsInternal.invalidate(); + } else { + await trpcUtils.template.findTemplates.invalidate(); + } + + toast({ + description: t`Selected items have been moved.`, + }); + + onSuccess?.(); + onOpenChange(false); + } catch (err) { + const error = AppError.parseError(err); + + const errorMessage = match(error.code) + .with( + AppErrorCode.NOT_FOUND, + () => t`The folder you are trying to move the items to does not exist.`, + ) + .with(AppErrorCode.UNAUTHORIZED, () => t`You are not allowed to move these items.`) + .with(AppErrorCode.INVALID_BODY, () => t`All items must be of the same type.`) + .otherwise(() => t`An error occurred while moving the items.`); + + toast({ + description: errorMessage, + variant: 'destructive', + }); + } + }; + + const filteredFolders = folders?.data.filter((folder) => + folder.name.toLowerCase().includes(searchTerm.toLowerCase()), + ); + + return ( + + + + + {isDocument ? ( + Move Documents to Folder + ) : ( + Move Templates to Folder + )} + + + + {isDocument ? ( + + ) : ( + + )} + + + +
+ + setSearchTerm(e.target.value)} + className="pl-8" + /> +
+ +
+ + ( + + + Folder + + + +
+ {isFoldersLoading ? ( +
+ +
+ ) : ( + <> + + + {filteredFolders?.map((folder) => ( + + ))} + + {searchTerm && filteredFolders?.length === 0 && ( +
+ No folders found +
+ )} + + )} +
+
+ +
+ )} + /> + + + + + + + + +
+
+ ); +}; diff --git a/apps/remix/app/components/tables/documents-table.tsx b/apps/remix/app/components/tables/documents-table.tsx index ebd4574c5..a9da6ac2d 100644 --- a/apps/remix/app/components/tables/documents-table.tsx +++ b/apps/remix/app/components/tables/documents-table.tsx @@ -12,7 +12,8 @@ import { useSession } from '@documenso/lib/client-only/providers/session'; import { isDocumentCompleted } from '@documenso/lib/utils/document'; import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import type { TFindDocumentsResponse } from '@documenso/trpc/server/document-router/find-documents.types'; -import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table'; +import { Checkbox } from '@documenso/ui/primitives/checkbox'; +import type { DataTableColumnDef, RowSelectionState } from '@documenso/ui/primitives/data-table'; import { DataTable } from '@documenso/ui/primitives/data-table'; import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; import { Skeleton } from '@documenso/ui/primitives/skeleton'; @@ -30,6 +31,9 @@ export type DocumentsTableProps = { isLoading?: boolean; isLoadingError?: boolean; onMoveDocument?: (documentId: number) => void; + enableSelection?: boolean; + rowSelection?: RowSelectionState; + onRowSelectionChange?: (selection: RowSelectionState) => void; }; type DocumentsTableRow = TFindDocumentsResponse['data'][number]; @@ -39,6 +43,9 @@ export const DocumentsTable = ({ isLoading, isLoadingError, onMoveDocument, + enableSelection, + rowSelection, + onRowSelectionChange, }: DocumentsTableProps) => { const { _, i18n } = useLingui(); @@ -48,7 +55,34 @@ export const DocumentsTable = ({ const updateSearchParams = useUpdateSearchParams(); const columns = useMemo(() => { - return [ + const cols: DataTableColumnDef[] = []; + + if (enableSelection) { + cols.push({ + id: 'select', + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label={_(msg`Select all`)} + onClick={(e) => e.stopPropagation()} + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label={_(msg`Select row`)} + onClick={(e) => e.stopPropagation()} + /> + ), + enableSorting: false, + enableHiding: false, + size: 40, + }); + } + + cols.push( { header: _(msg`Created`), accessorKey: 'createdAt', @@ -93,8 +127,10 @@ export const DocumentsTable = ({ ), }, - ] satisfies DataTableColumnDef[]; - }, [team, onMoveDocument]); + ); + + return cols; + }, [team, onMoveDocument, enableSelection]); const onPaginationChange = (page: number, perPage: number) => { startTransition(() => { @@ -132,6 +168,11 @@ export const DocumentsTable = ({ rows: 5, component: ( <> + {enableSelection && ( + + + + )} @@ -152,13 +193,17 @@ export const DocumentsTable = ({ ), }} + enableRowSelection={enableSelection} + rowSelection={rowSelection} + onRowSelectionChange={onRowSelectionChange} + getRowId={(row) => row.envelopeId} > {(table) => } {isPending && ( -
- +
+
)}
diff --git a/apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx b/apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx new file mode 100644 index 000000000..84ee783b7 --- /dev/null +++ b/apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx @@ -0,0 +1,55 @@ +import { useLingui } from '@lingui/react/macro'; +import { Trans } from '@lingui/react/macro'; +import { FolderInputIcon, Trash2Icon, XIcon } from 'lucide-react'; + +import { Button } from '@documenso/ui/primitives/button'; + +export type EnvelopesTableBulkActionBarProps = { + selectedCount: number; + onMoveClick: () => void; + onDeleteClick: () => void; + onClearSelection: () => void; +}; + +export const EnvelopesTableBulkActionBar = ({ + selectedCount, + onMoveClick, + onDeleteClick, + onClearSelection, +}: EnvelopesTableBulkActionBarProps) => { + const { t } = useLingui(); + + if (selectedCount === 0) { + return null; + } + + return ( +
+ + {selectedCount} selected + + +
+ + + + + + +
+ ); +}; diff --git a/apps/remix/app/components/tables/templates-table.tsx b/apps/remix/app/components/tables/templates-table.tsx index 59160ad89..df7ef6dc1 100644 --- a/apps/remix/app/components/tables/templates-table.tsx +++ b/apps/remix/app/components/tables/templates-table.tsx @@ -12,7 +12,8 @@ import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/org import { formatTemplatesPath } from '@documenso/lib/utils/teams'; import type { TFindTemplatesResponse } from '@documenso/trpc/server/template-router/schema'; import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; -import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table'; +import { Checkbox } from '@documenso/ui/primitives/checkbox'; +import type { DataTableColumnDef, RowSelectionState } from '@documenso/ui/primitives/data-table'; import { DataTable } from '@documenso/ui/primitives/data-table'; import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; import { Skeleton } from '@documenso/ui/primitives/skeleton'; @@ -32,6 +33,9 @@ type TemplatesTableProps = { isLoadingError?: boolean; documentRootPath: string; templateRootPath: string; + enableSelection?: boolean; + rowSelection?: RowSelectionState; + onRowSelectionChange?: (selection: RowSelectionState) => void; }; type TemplatesTableRow = TFindTemplatesResponse['data'][number]; @@ -42,6 +46,9 @@ export const TemplatesTable = ({ isLoadingError, documentRootPath, templateRootPath, + enableSelection, + rowSelection, + onRowSelectionChange, }: TemplatesTableProps) => { const { _, i18n } = useLingui(); const { remaining } = useLimits(); @@ -60,7 +67,34 @@ export const TemplatesTable = ({ }; const columns = useMemo(() => { - return [ + const cols: DataTableColumnDef[] = []; + + if (enableSelection) { + cols.push({ + id: 'select', + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label={_(msg`Select all`)} + onClick={(e) => e.stopPropagation()} + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label={_(msg`Select row`)} + onClick={(e) => e.stopPropagation()} + /> + ), + enableSorting: false, + enableHiding: false, + size: 40, + }); + } + + cols.push( { header: _(msg`Created`), accessorKey: 'createdAt', @@ -86,8 +120,8 @@ export const TemplatesTable = ({ - -
    + +
    • @@ -176,8 +210,10 @@ export const TemplatesTable = ({ ); }, }, - ] satisfies DataTableColumnDef[]; - }, [documentRootPath, team?.id, templateRootPath]); + ); + + return cols; + }, [documentRootPath, team?.id, templateRootPath, enableSelection]); const onPaginationChange = (page: number, perPage: number) => { startTransition(() => { @@ -224,6 +260,10 @@ export const TemplatesTable = ({ currentPage={results.currentPage} totalPages={results.totalPages} onPaginationChange={onPaginationChange} + enableRowSelection={enableSelection} + rowSelection={rowSelection} + onRowSelectionChange={onRowSelectionChange} + getRowId={(row) => row.envelopeId} error={{ enable: isLoadingError || false, }} @@ -232,6 +272,11 @@ export const TemplatesTable = ({ rows: 5, component: ( <> + {enableSelection && ( + + + + )} diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx index 58e80af6e..fd271aafe 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx @@ -16,9 +16,12 @@ import { trpc } from '@documenso/trpc/react'; import type { TFindDocumentsInternalResponse } from '@documenso/trpc/server/document-router/find-documents-internal.types'; import { ZFindDocumentsInternalRequestSchema } from '@documenso/trpc/server/document-router/find-documents-internal.types'; import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar'; +import type { RowSelectionState } from '@documenso/ui/primitives/data-table'; import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs'; import { DocumentMoveToFolderDialog } from '~/components/dialogs/document-move-to-folder-dialog'; +import { EnvelopesBulkDeleteDialog } from '~/components/dialogs/envelopes-bulk-delete-dialog'; +import { EnvelopesBulkMoveDialog } from '~/components/dialogs/envelopes-bulk-move-dialog'; import { DocumentSearch } from '~/components/general/document/document-search'; import { DocumentStatus } from '~/components/general/document/document-status'; import { EnvelopeDropZoneWrapper } from '~/components/general/envelope/envelope-drop-zone-wrapper'; @@ -27,6 +30,7 @@ import { PeriodSelector } from '~/components/general/period-selector'; import { DocumentsTable } from '~/components/tables/documents-table'; import { DocumentsTableEmptyState } from '~/components/tables/documents-table-empty-state'; import { DocumentsTableSenderFilter } from '~/components/tables/documents-table-sender-filter'; +import { EnvelopesTableBulkActionBar } from '~/components/tables/envelopes-table-bulk-action-bar'; import { useCurrentTeam } from '~/providers/team'; import { appMetaTags } from '~/utils/meta'; @@ -54,6 +58,14 @@ export default function DocumentsPage() { const [isMovingDocument, setIsMovingDocument] = useState(false); const [documentToMove, setDocumentToMove] = useState(null); + const [rowSelection, setRowSelection] = useState({}); + const [isBulkMoveDialogOpen, setIsBulkMoveDialogOpen] = useState(false); + const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false); + + const selectedEnvelopeIds = useMemo(() => { + return Object.keys(rowSelection).filter((id) => rowSelection[id]); + }, [rowSelection]); + const [stats, setStats] = useState({ [ExtendedDocumentStatus.DRAFT]: 0, [ExtendedDocumentStatus.PENDING]: 0, @@ -109,6 +121,11 @@ export default function DocumentsPage() { } }, [data?.stats]); + // Clear selection when navigation or filters change + useEffect(() => { + setRowSelection({}); + }, [folderId, findDocumentSearchParams]); + return (
      @@ -116,9 +133,9 @@ export default function DocumentsPage() {
      - + {team.avatarImageId && } - + {team.name.slice(0, 1)} @@ -148,7 +165,7 @@ export default function DocumentsPage() { .map((value) => ( @@ -190,6 +207,9 @@ export default function DocumentsPage() { setDocumentToMove(documentId); setIsMovingDocument(true); }} + enableSelection + rowSelection={rowSelection} + onRowSelectionChange={setRowSelection} /> )}
      @@ -209,6 +229,30 @@ export default function DocumentsPage() { }} /> )} + + setIsBulkMoveDialogOpen(true)} + onDeleteClick={() => setIsBulkDeleteDialogOpen(true)} + onClearSelection={() => setRowSelection({})} + /> + + setRowSelection({})} + /> + + setRowSelection({})} + />
      ); diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates._index.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates._index.tsx index c97df2739..955d5194f 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates._index.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates._index.tsx @@ -1,3 +1,5 @@ +import { useEffect, useMemo, useState } from 'react'; + import { Trans } from '@lingui/react/macro'; import { EnvelopeType } from '@prisma/client'; import { Bird } from 'lucide-react'; @@ -8,9 +10,13 @@ import { formatAvatarUrl } from '@documenso/lib/utils/avatars'; import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams'; import { trpc } from '@documenso/trpc/react'; import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar'; +import type { RowSelectionState } from '@documenso/ui/primitives/data-table'; +import { EnvelopesBulkDeleteDialog } from '~/components/dialogs/envelopes-bulk-delete-dialog'; +import { EnvelopesBulkMoveDialog } from '~/components/dialogs/envelopes-bulk-move-dialog'; import { EnvelopeDropZoneWrapper } from '~/components/general/envelope/envelope-drop-zone-wrapper'; import { FolderGrid } from '~/components/general/folder/folder-grid'; +import { EnvelopesTableBulkActionBar } from '~/components/tables/envelopes-table-bulk-action-bar'; import { TemplatesTable } from '~/components/tables/templates-table'; import { useCurrentTeam } from '~/providers/team'; import { appMetaTags } from '~/utils/meta'; @@ -28,6 +34,14 @@ export default function TemplatesPage() { const page = Number(searchParams.get('page')) || 1; const perPage = Number(searchParams.get('perPage')) || 10; + const [rowSelection, setRowSelection] = useState({}); + const [isBulkMoveDialogOpen, setIsBulkMoveDialogOpen] = useState(false); + const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false); + + const selectedEnvelopeIds = useMemo(() => { + return Object.keys(rowSelection).filter((id) => rowSelection[id]); + }, [rowSelection]); + const documentRootPath = formatDocumentsPath(team.url); const templateRootPath = formatTemplatesPath(team.url); @@ -37,6 +51,11 @@ export default function TemplatesPage() { folderId, }); + // Clear selection when navigation or filters change + useEffect(() => { + setRowSelection({}); + }, [folderId, page, perPage]); + return (
      @@ -44,9 +63,9 @@ export default function TemplatesPage() {
      - + {team.avatarImageId && } - + {team.name.slice(0, 1)} @@ -58,7 +77,7 @@ export default function TemplatesPage() {
      {data && data.count === 0 ? ( -
      +
      @@ -81,10 +100,37 @@ export default function TemplatesPage() { isLoadingError={isLoadingError} documentRootPath={documentRootPath} templateRootPath={templateRootPath} + enableSelection + rowSelection={rowSelection} + onRowSelectionChange={setRowSelection} /> )}
      + + setIsBulkMoveDialogOpen(true)} + onDeleteClick={() => setIsBulkDeleteDialogOpen(true)} + onClearSelection={() => setRowSelection({})} + /> + + setRowSelection({})} + /> + + setRowSelection({})} + />
      ); diff --git a/packages/app-tests/e2e/api/v2/test-unauthorized-api-access.spec.ts b/packages/app-tests/e2e/api/v2/test-unauthorized-api-access.spec.ts index 5dc4794e3..e7eb70195 100644 --- a/packages/app-tests/e2e/api/v2/test-unauthorized-api-access.spec.ts +++ b/packages/app-tests/e2e/api/v2/test-unauthorized-api-access.spec.ts @@ -4730,252 +4730,6 @@ test.describe('Document API V2', () => { }); }); - test.describe('Envelope item delete endpoint', () => { - test('should block unauthorized access to envelope item delete endpoint', async ({ - request, - }) => { - const doc = await seedBlankDocument(userA, teamA.id); - - const envelopeItem = await prisma.envelopeItem.findFirstOrThrow({ - where: { envelopeId: doc.id }, - }); - - const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/item/delete`, { - headers: { Authorization: `Bearer ${tokenB}` }, - data: { - envelopeId: doc.id, - envelopeItemId: envelopeItem.id, - }, - }); - - expect(res.ok()).toBeFalsy(); - expect(res.status()).toBe(404); - }); - - test('should allow authorized access to envelope item delete endpoint', async ({ - request, - }) => { - const doc = await seedBlankDocument(userA, teamA.id); - - const envelopeItem = await prisma.envelopeItem.findFirstOrThrow({ - where: { envelopeId: doc.id }, - }); - - const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/item/delete`, { - headers: { Authorization: `Bearer ${tokenA}` }, - data: { - envelopeId: doc.id, - envelopeItemId: envelopeItem.id, - }, - }); - - expect(res.ok()).toBeTruthy(); - expect(res.status()).toBe(200); - }); - }); - - test.describe('Envelope attachment find endpoint', () => { - test('should block unauthorized access to envelope attachment find endpoint', async ({ - request, - }) => { - const doc = await seedBlankDocument(userA, teamA.id); - - const res = await request.get( - `${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment?envelopeId=${doc.id}`, - { - headers: { Authorization: `Bearer ${tokenB}` }, - }, - ); - - expect(res.ok()).toBeFalsy(); - expect(res.status()).toBe(404); - }); - - test('should allow authorized access to envelope attachment find endpoint', async ({ - request, - }) => { - const doc = await seedBlankDocument(userA, teamA.id); - - const res = await request.get( - `${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment?envelopeId=${doc.id}`, - { - headers: { Authorization: `Bearer ${tokenA}` }, - }, - ); - - expect(res.ok()).toBeTruthy(); - expect(res.status()).toBe(200); - }); - }); - - test.describe('Envelope attachment create endpoint', () => { - test('should block unauthorized access to envelope attachment create endpoint', async ({ - request, - }) => { - const doc = await seedBlankDocument(userA, teamA.id); - - const res = await request.post( - `${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/create`, - { - headers: { Authorization: `Bearer ${tokenB}` }, - data: { - envelopeId: doc.id, - data: { - label: 'Test Attachment', - data: 'https://example.com/file.pdf', - }, - }, - }, - ); - - expect(res.ok()).toBeFalsy(); - expect(res.status()).toBe(404); - }); - - test('should allow authorized access to envelope attachment create endpoint', async ({ - request, - }) => { - const doc = await seedBlankDocument(userA, teamA.id); - - const res = await request.post( - `${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/create`, - { - headers: { Authorization: `Bearer ${tokenA}` }, - data: { - envelopeId: doc.id, - data: { - label: 'Test Attachment', - data: 'https://example.com/file.pdf', - }, - }, - }, - ); - - expect(res.ok()).toBeTruthy(); - expect(res.status()).toBe(200); - }); - }); - - test.describe('Envelope attachment update endpoint', () => { - test('should block unauthorized access to envelope attachment update endpoint', async ({ - request, - }) => { - const doc = await seedBlankDocument(userA, teamA.id); - - const attachment = await prisma.envelopeAttachment.create({ - data: { - envelopeId: doc.id, - type: 'link', - label: 'Original Label', - data: 'https://example.com/original.pdf', - }, - }); - - const res = await request.post( - `${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/update`, - { - headers: { Authorization: `Bearer ${tokenB}` }, - data: { - id: attachment.id, - data: { - label: 'Updated Label', - data: 'https://example.com/updated.pdf', - }, - }, - }, - ); - - expect(res.ok()).toBeFalsy(); - expect(res.status()).toBe(404); - }); - - test('should allow authorized access to envelope attachment update endpoint', async ({ - request, - }) => { - const doc = await seedBlankDocument(userA, teamA.id); - - const attachment = await prisma.envelopeAttachment.create({ - data: { - envelopeId: doc.id, - type: 'link', - label: 'Original Label', - data: 'https://example.com/original.pdf', - }, - }); - - const res = await request.post( - `${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/update`, - { - headers: { Authorization: `Bearer ${tokenA}` }, - data: { - id: attachment.id, - data: { - label: 'Updated Label', - data: 'https://example.com/updated.pdf', - }, - }, - }, - ); - - expect(res.ok()).toBeTruthy(); - expect(res.status()).toBe(200); - }); - }); - - test.describe('Envelope attachment delete endpoint', () => { - test('should block unauthorized access to envelope attachment delete endpoint', async ({ - request, - }) => { - const doc = await seedBlankDocument(userA, teamA.id); - - const attachment = await prisma.envelopeAttachment.create({ - data: { - envelopeId: doc.id, - type: 'link', - label: 'Test Attachment', - data: 'https://example.com/file.pdf', - }, - }); - - const res = await request.post( - `${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/delete`, - { - headers: { Authorization: `Bearer ${tokenB}` }, - data: { id: attachment.id }, - }, - ); - - expect(res.ok()).toBeFalsy(); - expect(res.status()).toBe(404); - }); - - test('should allow authorized access to envelope attachment delete endpoint', async ({ - request, - }) => { - const doc = await seedBlankDocument(userA, teamA.id); - - const attachment = await prisma.envelopeAttachment.create({ - data: { - envelopeId: doc.id, - type: 'link', - label: 'Test Attachment', - data: 'https://example.com/file.pdf', - }, - }); - - const res = await request.post( - `${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/delete`, - { - headers: { Authorization: `Bearer ${tokenA}` }, - data: { id: attachment.id }, - }, - ); - - expect(res.ok()).toBeTruthy(); - expect(res.status()).toBe(200); - }); - }); - test.describe('Envelope audit logs endpoint', () => { test('should block unauthorized access to envelope audit logs endpoint', async ({ request, diff --git a/packages/app-tests/e2e/api/v2/unauthorized-api-access/api-access-envelope-attachments.spec.ts b/packages/app-tests/e2e/api/v2/unauthorized-api-access/api-access-envelope-attachments.spec.ts new file mode 100644 index 000000000..76e0c748e --- /dev/null +++ b/packages/app-tests/e2e/api/v2/unauthorized-api-access/api-access-envelope-attachments.spec.ts @@ -0,0 +1,220 @@ +import { expect, test } from '@playwright/test'; +import type { Team, User } from '@prisma/client'; + +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token'; +import { prisma } from '@documenso/prisma'; +import { seedBlankDocument } from '@documenso/prisma/seed/documents'; +import { seedUser } from '@documenso/prisma/seed/users'; + +const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL(); + +test.describe.configure({ + mode: 'parallel', +}); + +test.describe('Envelope Attachments API V2', () => { + let userA: User, teamA: Team, userB: User, teamB: Team, tokenA: string, tokenB: string; + + test.beforeEach(async () => { + ({ user: userA, team: teamA } = await seedUser()); + ({ token: tokenA } = await createApiToken({ + userId: userA.id, + teamId: teamA.id, + tokenName: 'userA', + expiresIn: null, + })); + + ({ user: userB, team: teamB } = await seedUser()); + ({ token: tokenB } = await createApiToken({ + userId: userB.id, + teamId: teamB.id, + tokenName: 'userB', + expiresIn: null, + })); + }); + + test.describe('Envelope attachment find endpoint', () => { + test('should block unauthorized access to envelope attachment find endpoint', async ({ + request, + }) => { + const doc = await seedBlankDocument(userA, teamA.id); + + const res = await request.get( + `${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment?envelopeId=${doc.id}`, + { + headers: { Authorization: `Bearer ${tokenB}` }, + }, + ); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(404); + }); + + test('should allow authorized access to envelope attachment find endpoint', async ({ + request, + }) => { + const doc = await seedBlankDocument(userA, teamA.id); + + const res = await request.get( + `${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment?envelopeId=${doc.id}`, + { + headers: { Authorization: `Bearer ${tokenA}` }, + }, + ); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + }); + }); + + test.describe('Envelope attachment create endpoint', () => { + test('should block unauthorized access to envelope attachment create endpoint', async ({ + request, + }) => { + const doc = await seedBlankDocument(userA, teamA.id); + + const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/create`, { + headers: { Authorization: `Bearer ${tokenB}` }, + data: { + envelopeId: doc.id, + data: { + label: 'Test Attachment', + data: 'https://example.com/file.pdf', + }, + }, + }); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(404); + }); + + test('should allow authorized access to envelope attachment create endpoint', async ({ + request, + }) => { + const doc = await seedBlankDocument(userA, teamA.id); + + const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/create`, { + headers: { Authorization: `Bearer ${tokenA}` }, + data: { + envelopeId: doc.id, + data: { + label: 'Test Attachment', + data: 'https://example.com/file.pdf', + }, + }, + }); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + }); + }); + + test.describe('Envelope attachment update endpoint', () => { + test('should block unauthorized access to envelope attachment update endpoint', async ({ + request, + }) => { + const doc = await seedBlankDocument(userA, teamA.id); + + const attachment = await prisma.envelopeAttachment.create({ + data: { + envelopeId: doc.id, + type: 'link', + label: 'Original Label', + data: 'https://example.com/original.pdf', + }, + }); + + const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/update`, { + headers: { Authorization: `Bearer ${tokenB}` }, + data: { + id: attachment.id, + data: { + label: 'Updated Label', + data: 'https://example.com/updated.pdf', + }, + }, + }); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(404); + }); + + test('should allow authorized access to envelope attachment update endpoint', async ({ + request, + }) => { + const doc = await seedBlankDocument(userA, teamA.id); + + const attachment = await prisma.envelopeAttachment.create({ + data: { + envelopeId: doc.id, + type: 'link', + label: 'Original Label', + data: 'https://example.com/original.pdf', + }, + }); + + const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/update`, { + headers: { Authorization: `Bearer ${tokenA}` }, + data: { + id: attachment.id, + data: { + label: 'Updated Label', + data: 'https://example.com/updated.pdf', + }, + }, + }); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + }); + }); + + test.describe('Envelope attachment delete endpoint', () => { + test('should block unauthorized access to envelope attachment delete endpoint', async ({ + request, + }) => { + const doc = await seedBlankDocument(userA, teamA.id); + + const attachment = await prisma.envelopeAttachment.create({ + data: { + envelopeId: doc.id, + type: 'link', + label: 'Test Attachment', + data: 'https://example.com/file.pdf', + }, + }); + + const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/delete`, { + headers: { Authorization: `Bearer ${tokenB}` }, + data: { id: attachment.id }, + }); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(404); + }); + + test('should allow authorized access to envelope attachment delete endpoint', async ({ + request, + }) => { + const doc = await seedBlankDocument(userA, teamA.id); + + const attachment = await prisma.envelopeAttachment.create({ + data: { + envelopeId: doc.id, + type: 'link', + label: 'Test Attachment', + data: 'https://example.com/file.pdf', + }, + }); + + const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/delete`, { + headers: { Authorization: `Bearer ${tokenA}` }, + data: { id: attachment.id }, + }); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + }); + }); +}); diff --git a/packages/app-tests/e2e/api/v2/unauthorized-api-access/api-access-envelope-bulk.spec.ts b/packages/app-tests/e2e/api/v2/unauthorized-api-access/api-access-envelope-bulk.spec.ts new file mode 100644 index 000000000..8540876cd --- /dev/null +++ b/packages/app-tests/e2e/api/v2/unauthorized-api-access/api-access-envelope-bulk.spec.ts @@ -0,0 +1,390 @@ +import { expect, test } from '@playwright/test'; +import type { Team, User } from '@prisma/client'; +import { EnvelopeType, FolderType } from '@prisma/client'; + +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token'; +import { prisma } from '@documenso/prisma'; +import { seedBlankDocument } from '@documenso/prisma/seed/documents'; +import { seedBlankFolder } from '@documenso/prisma/seed/folders'; +import { seedUser } from '@documenso/prisma/seed/users'; + +const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL(); + +test.describe.configure({ + mode: 'parallel', +}); + +// Todo: Remove skip once the API endpoints are released. +test.describe.skip('Envelope Bulk API V2', () => { + let userA: User, teamA: Team, userB: User, teamB: Team, tokenA: string, tokenB: string; + + test.beforeEach(async () => { + ({ user: userA, team: teamA } = await seedUser()); + ({ token: tokenA } = await createApiToken({ + userId: userA.id, + teamId: teamA.id, + tokenName: 'userA', + expiresIn: null, + })); + + ({ user: userB, team: teamB } = await seedUser()); + ({ token: tokenB } = await createApiToken({ + userId: userB.id, + teamId: teamB.id, + tokenName: 'userB', + expiresIn: null, + })); + }); + + test.describe('Envelope bulk move endpoint', () => { + test('should block unauthorized access to envelope bulk move endpoint', async ({ request }) => { + // Create a document owned by userA + const doc = await seedBlankDocument(userA, teamA.id); + + // UserB tries to move userA's document + const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/move`, { + headers: { Authorization: `Bearer ${tokenB}` }, + data: { + envelopeIds: [doc.id], + envelopeType: EnvelopeType.DOCUMENT, + folderId: null, + }, + }); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + + const body = await res.json(); + expect(body.movedCount).toBe(0); + + // Verify in database that the document was not modified + const docInDb = await prisma.envelope.findFirst({ + where: { id: doc.id }, + }); + + expect(docInDb).not.toBeNull(); + expect(docInDb?.folderId).toBeNull(); + }); + + test('should block moving envelopes to unauthorized folder', async ({ request }) => { + // Create a document owned by userB + const doc = await seedBlankDocument(userB, teamB.id); + + // Create a folder owned by userA + const folderA = await seedBlankFolder(userA, teamA.id, { + createFolderOptions: { + name: 'UserA Folder', + type: FolderType.DOCUMENT, + }, + }); + + // UserB tries to move their document to userA's folder + const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/move`, { + headers: { Authorization: `Bearer ${tokenB}` }, + data: { + envelopeIds: [doc.id], + envelopeType: EnvelopeType.DOCUMENT, + folderId: folderA.id, + }, + }); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(404); + + // Verify in database that the document was not modified + const docInDb = await prisma.envelope.findFirst({ + where: { id: doc.id }, + }); + + expect(docInDb).not.toBeNull(); + expect(docInDb?.folderId).toBeNull(); + }); + + test('should allow authorized access to envelope bulk move endpoint', async ({ request }) => { + // Create a document owned by userA + const doc = await seedBlankDocument(userA, teamA.id); + + // Create a folder owned by userA + const folderA = await seedBlankFolder(userA, teamA.id, { + createFolderOptions: { + name: 'UserA Folder', + type: FolderType.DOCUMENT, + }, + }); + + // UserA moves their own document to their own folder + const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/move`, { + headers: { Authorization: `Bearer ${tokenA}` }, + data: { + envelopeIds: [doc.id], + envelopeType: EnvelopeType.DOCUMENT, + folderId: folderA.id, + }, + }); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + + const body = await res.json(); + expect(body.movedCount).toBe(1); + + // Verify in database that the document was moved to the folder + const docInDb = await prisma.envelope.findFirst({ + where: { id: doc.id }, + }); + + expect(docInDb).not.toBeNull(); + expect(docInDb?.folderId).toBe(folderA.id); + }); + + test('should only move authorized envelopes when given mixed array of envelope IDs', async ({ + request, + }) => { + // Create documents owned by userA + const docA1 = await seedBlankDocument(userA, teamA.id); + const docA2 = await seedBlankDocument(userA, teamA.id); + + // Create a document owned by userB + const docB = await seedBlankDocument(userB, teamB.id); + + // Create a folder owned by userA + const folderA = await seedBlankFolder(userA, teamA.id, { + createFolderOptions: { + name: 'UserA Folder', + type: FolderType.DOCUMENT, + }, + }); + + // UserA tries to move a mix of their own documents and userB's document + const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/move`, { + headers: { Authorization: `Bearer ${tokenA}` }, + data: { + envelopeIds: [docA1.id, docB.id, docA2.id], + envelopeType: EnvelopeType.DOCUMENT, + folderId: folderA.id, + }, + }); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + + const body = await res.json(); + // Only userA's documents should be moved + expect(body.movedCount).toBe(2); + + // Verify userA's documents were moved + const docA1InDb = await prisma.envelope.findFirst({ + where: { id: docA1.id }, + }); + expect(docA1InDb).not.toBeNull(); + expect(docA1InDb?.folderId).toBe(folderA.id); + + const docA2InDb = await prisma.envelope.findFirst({ + where: { id: docA2.id }, + }); + expect(docA2InDb).not.toBeNull(); + expect(docA2InDb?.folderId).toBe(folderA.id); + + // Verify userB's document was NOT moved + const docBInDb = await prisma.envelope.findFirst({ + where: { id: docB.id }, + }); + expect(docBInDb).not.toBeNull(); + expect(docBInDb?.folderId).toBeNull(); + }); + + test('should move zero envelopes when all envelope IDs in array are unauthorized', async ({ + request, + }) => { + // Create documents owned by userB + const docB1 = await seedBlankDocument(userB, teamB.id); + const docB2 = await seedBlankDocument(userB, teamB.id); + + // Create a folder owned by userA + const folderA = await seedBlankFolder(userA, teamA.id, { + createFolderOptions: { + name: 'UserA Folder', + type: FolderType.DOCUMENT, + }, + }); + + // UserA tries to move userB's documents + const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/move`, { + headers: { Authorization: `Bearer ${tokenA}` }, + data: { + envelopeIds: [docB1.id, docB2.id], + envelopeType: EnvelopeType.DOCUMENT, + folderId: folderA.id, + }, + }); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + + const body = await res.json(); + expect(body.movedCount).toBe(0); + + // Verify userB's documents were NOT moved + const docB1InDb = await prisma.envelope.findFirst({ + where: { id: docB1.id }, + }); + expect(docB1InDb).not.toBeNull(); + expect(docB1InDb?.folderId).toBeNull(); + + const docB2InDb = await prisma.envelope.findFirst({ + where: { id: docB2.id }, + }); + expect(docB2InDb).not.toBeNull(); + expect(docB2InDb?.folderId).toBeNull(); + }); + }); + + test.describe('Envelope bulk delete endpoint', () => { + test('should block unauthorized access to envelope bulk delete endpoint', async ({ + request, + }) => { + // Create a document owned by userA + const doc = await seedBlankDocument(userA, teamA.id); + + // UserB tries to delete userA's document + const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/delete`, { + headers: { Authorization: `Bearer ${tokenB}` }, + data: { + envelopeIds: [doc.id], + }, + }); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + + const body = await res.json(); + expect(body.deletedCount).toBe(0); + // Unauthorized envelope ID should be in failedIds + expect(body.failedIds).toEqual([doc.id]); + + // Verify in database that the document still exists + const docInDb = await prisma.envelope.findFirst({ + where: { id: doc.id }, + }); + + expect(docInDb).not.toBeNull(); + expect(docInDb?.id).toBe(doc.id); + expect(docInDb?.deletedAt).toBeNull(); + }); + + test('should allow authorized access to envelope bulk delete endpoint', async ({ request }) => { + // Create a document owned by userA + const doc = await seedBlankDocument(userA, teamA.id); + + // UserA deletes their own document + const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/delete`, { + headers: { Authorization: `Bearer ${tokenA}` }, + data: { + envelopeIds: [doc.id], + }, + }); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + + const body = await res.json(); + expect(body.deletedCount).toBe(1); + expect(body.failedIds).toEqual([]); + + // Verify in database that the document no longer exists + const docInDb = await prisma.envelope.findFirst({ + where: { id: doc.id }, + }); + + expect(docInDb).toBeNull(); + }); + + test('should only delete authorized envelopes when given mixed array of envelope IDs', async ({ + request, + }) => { + // Create documents owned by userA + const docA1 = await seedBlankDocument(userA, teamA.id); + const docA2 = await seedBlankDocument(userA, teamA.id); + + // Create a document owned by userB + const docB = await seedBlankDocument(userB, teamB.id); + + // UserA tries to delete a mix of their own documents and userB's document + const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/delete`, { + headers: { Authorization: `Bearer ${tokenA}` }, + data: { + envelopeIds: [docA1.id, docB.id, docA2.id], + }, + }); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + + const body = await res.json(); + // Only userA's documents should be deleted + expect(body.deletedCount).toBe(2); + // Unauthorized envelope ID (docB) should be in failedIds + expect(body.failedIds).toEqual([docB.id]); + + // Verify userA's documents were deleted + const docA1InDb = await prisma.envelope.findFirst({ + where: { id: docA1.id }, + }); + expect(docA1InDb).toBeNull(); + + const docA2InDb = await prisma.envelope.findFirst({ + where: { id: docA2.id }, + }); + expect(docA2InDb).toBeNull(); + + // Verify userB's document was NOT deleted + const docBInDb = await prisma.envelope.findFirst({ + where: { id: docB.id }, + }); + expect(docBInDb).not.toBeNull(); + expect(docBInDb?.id).toBe(docB.id); + expect(docBInDb?.deletedAt).toBeNull(); + }); + + test('should delete zero envelopes when all envelope IDs in array are unauthorized', async ({ + request, + }) => { + // Create documents owned by userB + const docB1 = await seedBlankDocument(userB, teamB.id); + const docB2 = await seedBlankDocument(userB, teamB.id); + + // UserA tries to delete userB's documents + const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/delete`, { + headers: { Authorization: `Bearer ${tokenA}` }, + data: { + envelopeIds: [docB1.id, docB2.id], + }, + }); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + + const body = await res.json(); + expect(body.deletedCount).toBe(0); + // All unauthorized envelope IDs should be in failedIds + expect(body.failedIds).toEqual(expect.arrayContaining([docB1.id, docB2.id])); + expect(body.failedIds).toHaveLength(2); + + // Verify userB's documents were NOT deleted + const docB1InDb = await prisma.envelope.findFirst({ + where: { id: docB1.id }, + }); + expect(docB1InDb).not.toBeNull(); + expect(docB1InDb?.id).toBe(docB1.id); + expect(docB1InDb?.deletedAt).toBeNull(); + + const docB2InDb = await prisma.envelope.findFirst({ + where: { id: docB2.id }, + }); + expect(docB2InDb).not.toBeNull(); + expect(docB2InDb?.id).toBe(docB2.id); + expect(docB2InDb?.deletedAt).toBeNull(); + }); + }); +}); diff --git a/packages/app-tests/e2e/documents/bulk-document-actions.spec.ts b/packages/app-tests/e2e/documents/bulk-document-actions.spec.ts new file mode 100644 index 000000000..3c6ba0bc2 --- /dev/null +++ b/packages/app-tests/e2e/documents/bulk-document-actions.spec.ts @@ -0,0 +1,253 @@ +import { expect, test } from '@playwright/test'; + +import { seedDraftDocument } from '@documenso/prisma/seed/documents'; +import { seedBlankFolder } from '@documenso/prisma/seed/folders'; +import { seedUser } from '@documenso/prisma/seed/users'; + +import { apiSignin } from '../fixtures/authentication'; +import { expectToastTextToBeVisible } from '../fixtures/generic'; + +test.describe.configure({ mode: 'parallel' }); + +const seedBulkActionsTestRequirements = async () => { + const sender = await seedUser({ setTeamEmailAsOwner: true }); + + const [doc1, doc2, doc3] = await Promise.all([ + seedDraftDocument(sender.user, sender.team.id, [], { + createDocumentOptions: { title: 'Bulk Test Doc 1' }, + }), + seedDraftDocument(sender.user, sender.team.id, [], { + createDocumentOptions: { title: 'Bulk Test Doc 2' }, + }), + seedDraftDocument(sender.user, sender.team.id, [], { + createDocumentOptions: { title: 'Bulk Test Doc 3' }, + }), + ]); + + const folder = await seedBlankFolder(sender.user, sender.team.id, { + createFolderOptions: { + name: 'Target Folder', + teamId: sender.team.id, + }, + }); + + return { + sender, + documents: [doc1, doc2, doc3], + folder, + }; +}; + +test('[BULK_ACTIONS]: can select multiple documents with checkboxes', async ({ page }) => { + const { sender } = await seedBulkActionsTestRequirements(); + + await apiSignin({ + page, + email: sender.user.email, + redirectPath: `/t/${sender.team.url}/documents`, + }); + + await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click(); + await expect(page.getByText('1 selected')).toBeVisible(); + + await page.locator('tr', { hasText: 'Bulk Test Doc 2' }).getByRole('checkbox').click(); + await expect(page.getByText('2 selected')).toBeVisible(); +}); + +test('[BULK_ACTIONS]: header checkbox selects all documents on page', async ({ page }) => { + const { sender, documents } = await seedBulkActionsTestRequirements(); + + await apiSignin({ + page, + email: sender.user.email, + redirectPath: `/t/${sender.team.url}/documents`, + }); + + await page.locator('thead').getByRole('checkbox').click(); + + await expect(page.getByText(`${documents.length} selected`)).toBeVisible(); +}); + +test('[BULK_ACTIONS]: can clear selection with X button', async ({ page }) => { + const { sender } = await seedBulkActionsTestRequirements(); + + await apiSignin({ + page, + email: sender.user.email, + redirectPath: `/t/${sender.team.url}/documents`, + }); + + await page.locator('thead').getByRole('checkbox').click(); + await expect(page.getByText(/\d+ selected/)).toBeVisible(); + + await page.getByLabel('Clear selection').click(); + + await expect(page.getByText(/\d+ selected/)).not.toBeVisible(); +}); + +test('[BULK_ACTIONS]: can move multiple documents to a folder', async ({ page }) => { + const { sender, folder } = await seedBulkActionsTestRequirements(); + + await apiSignin({ + page, + email: sender.user.email, + redirectPath: `/t/${sender.team.url}/documents`, + }); + + await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click(); + await page.locator('tr', { hasText: 'Bulk Test Doc 2' }).getByRole('checkbox').click(); + await page.getByRole('button', { name: 'Move to Folder' }).click(); + + await expect(page.getByRole('dialog')).toBeVisible(); + await expect(page.getByText('Move Documents to Folder')).toBeVisible(); + + await page.getByRole('button', { name: folder.name }).click(); + await page.getByRole('button', { name: 'Move' }).click(); + + await expectToastTextToBeVisible(page, 'Selected items have been moved.'); + + await page.goto(`/t/${sender.team.url}/documents/f/${folder.id}`); + await expect(page.getByRole('link', { name: 'Bulk Test Doc 1' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Bulk Test Doc 2' })).toBeVisible(); +}); + +test('[BULK_ACTIONS]: can delete multiple draft documents', async ({ page }) => { + const { sender } = await seedBulkActionsTestRequirements(); + + await apiSignin({ + page, + email: sender.user.email, + redirectPath: `/t/${sender.team.url}/documents`, + }); + + await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click(); + await page.locator('tr', { hasText: 'Bulk Test Doc 2' }).getByRole('checkbox').click(); + + await page.getByRole('button', { name: 'Delete' }).click(); + + await expect(page.getByRole('dialog')).toBeVisible(); + await expect(page.getByText('Delete Documents')).toBeVisible(); + await expect(page.getByText('You are about to delete 2 documents')).toBeVisible(); + await expect(page.getByText('irreversible')).toBeVisible(); + + await page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click(); + + await expectToastTextToBeVisible(page, 'Documents deleted'); + + await expect(page.getByRole('link', { name: 'Bulk Test Doc 1' })).not.toBeVisible(); + await expect(page.getByRole('link', { name: 'Bulk Test Doc 2' })).not.toBeVisible(); + + await expect(page.getByRole('link', { name: 'Bulk Test Doc 3' })).toBeVisible(); +}); + +test('[BULK_ACTIONS]: selection clears after successful move', async ({ page }) => { + const { sender, folder } = await seedBulkActionsTestRequirements(); + + await apiSignin({ + page, + email: sender.user.email, + redirectPath: `/t/${sender.team.url}/documents`, + }); + + await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click(); + await expect(page.getByText('1 selected')).toBeVisible(); + + await page.getByRole('button', { name: 'Move to Folder' }).click(); + await page.getByRole('button', { name: folder.name }).click(); + await page.getByRole('button', { name: 'Move' }).click(); + + await expectToastTextToBeVisible(page, 'Selected items have been moved.'); + await expect(page.getByText(/\d+ selected/)).not.toBeVisible(); +}); + +test('[BULK_ACTIONS]: selection clears after successful delete', async ({ page }) => { + const { sender } = await seedBulkActionsTestRequirements(); + + await apiSignin({ + page, + email: sender.user.email, + redirectPath: `/t/${sender.team.url}/documents`, + }); + + await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click(); + await expect(page.getByText('1 selected')).toBeVisible(); + + await page.getByRole('button', { name: 'Delete' }).click(); + await page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click(); + + await expectToastTextToBeVisible(page, 'Documents deleted'); + await expect(page.getByText(/\d+ selected/)).not.toBeVisible(); +}); + +test('[BULK_ACTIONS]: can search for folders in move dialog', async ({ page }) => { + const { sender, folder } = await seedBulkActionsTestRequirements(); + + const otherFolder = await seedBlankFolder(sender.user, sender.team.id, { + createFolderOptions: { + name: 'Other Folder', + teamId: sender.team.id, + }, + }); + + await apiSignin({ + page, + email: sender.user.email, + redirectPath: `/t/${sender.team.url}/documents`, + }); + + await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click(); + + await page.getByRole('button', { name: 'Move to Folder' }).click(); + await expect(page.getByRole('dialog')).toBeVisible(); + + await expect(page.getByRole('button', { name: folder.name })).toBeVisible(); + await expect(page.getByRole('button', { name: otherFolder.name })).toBeVisible(); + + await page.getByPlaceholder('Search folders...').fill('Target'); + await expect(page.getByRole('button', { name: folder.name })).toBeVisible(); + await expect(page.getByRole('button', { name: otherFolder.name })).not.toBeVisible(); + + await page.getByPlaceholder('Search folders...').fill('Other'); + await expect(page.getByRole('button', { name: folder.name })).not.toBeVisible(); + await expect(page.getByRole('button', { name: otherFolder.name })).toBeVisible(); + + await page.getByPlaceholder('Search folders...').fill('NonExistent'); + await expect(page.getByText('No folders found')).toBeVisible(); +}); + +test('[BULK_ACTIONS]: can move documents from folder to home (root)', async ({ page }) => { + const { sender, documents, folder } = await seedBulkActionsTestRequirements(); + + const { prisma } = await import('@documenso/prisma'); + + await prisma.envelope.updateMany({ + where: { id: documents[0].id }, + data: { folderId: folder.id }, + }); + + await apiSignin({ + page, + email: sender.user.email, + redirectPath: `/t/${sender.team.url}/documents/f/${folder.id}`, + }); + + await expect(page.getByRole('link', { name: 'Bulk Test Doc 1' })).toBeVisible(); + + await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click(); + await expect(page.getByText('1 selected')).toBeVisible(); + + await page.getByRole('button', { name: 'Move to Folder' }).click(); + await expect(page.getByRole('dialog')).toBeVisible(); + + await page.getByRole('button', { name: 'Home (No Folder)' }).click(); + + await page.getByRole('button', { name: 'Move' }).click(); + + await expectToastTextToBeVisible(page, 'Selected items have been moved.'); + + await page.goto(`/t/${sender.team.url}/documents`); + await expect(page.getByRole('link', { name: 'Bulk Test Doc 1' })).toBeVisible(); + + await page.goto(`/t/${sender.team.url}/documents/f/${folder.id}`); + await expect(page.getByRole('link', { name: 'Bulk Test Doc 1' })).not.toBeVisible(); +}); diff --git a/packages/app-tests/e2e/templates/bulk-template-actions.spec.ts b/packages/app-tests/e2e/templates/bulk-template-actions.spec.ts new file mode 100644 index 000000000..097717d0b --- /dev/null +++ b/packages/app-tests/e2e/templates/bulk-template-actions.spec.ts @@ -0,0 +1,256 @@ +import { expect, test } from '@playwright/test'; + +import { FolderType } from '@documenso/prisma/client'; +import { seedBlankFolder } from '@documenso/prisma/seed/folders'; +import { seedBlankTemplate } from '@documenso/prisma/seed/templates'; +import { seedUser } from '@documenso/prisma/seed/users'; + +import { apiSignin } from '../fixtures/authentication'; +import { expectToastTextToBeVisible } from '../fixtures/generic'; + +test.describe.configure({ mode: 'parallel' }); + +const seedBulkActionsTestRequirements = async () => { + const sender = await seedUser({ setTeamEmailAsOwner: true }); + + const [template1, template2, template3] = await Promise.all([ + seedBlankTemplate(sender.user, sender.team.id, { + createTemplateOptions: { title: 'Bulk Test Template 1' }, + }), + seedBlankTemplate(sender.user, sender.team.id, { + createTemplateOptions: { title: 'Bulk Test Template 2' }, + }), + seedBlankTemplate(sender.user, sender.team.id, { + createTemplateOptions: { title: 'Bulk Test Template 3' }, + }), + ]); + + const folder = await seedBlankFolder(sender.user, sender.team.id, { + createFolderOptions: { + name: 'Target Template Folder', + teamId: sender.team.id, + type: FolderType.TEMPLATE, + }, + }); + + return { + sender, + templates: [template1, template2, template3], + folder, + }; +}; + +test('[BULK_ACTIONS]: can select multiple templates with checkboxes', async ({ page }) => { + const { sender } = await seedBulkActionsTestRequirements(); + + await apiSignin({ + page, + email: sender.user.email, + redirectPath: `/t/${sender.team.url}/templates`, + }); + + await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click(); + await expect(page.getByText('1 selected')).toBeVisible(); + + await page.locator('tr', { hasText: 'Bulk Test Template 2' }).getByRole('checkbox').click(); + await expect(page.getByText('2 selected')).toBeVisible(); +}); + +test('[BULK_ACTIONS]: header checkbox selects all templates on page', async ({ page }) => { + const { sender, templates } = await seedBulkActionsTestRequirements(); + + await apiSignin({ + page, + email: sender.user.email, + redirectPath: `/t/${sender.team.url}/templates`, + }); + + await page.locator('thead').getByRole('checkbox').click(); + + await expect(page.getByText(`${templates.length} selected`)).toBeVisible(); +}); + +test('[BULK_ACTIONS]: can clear selection with X button', async ({ page }) => { + const { sender } = await seedBulkActionsTestRequirements(); + + await apiSignin({ + page, + email: sender.user.email, + redirectPath: `/t/${sender.team.url}/templates`, + }); + + await page.locator('thead').getByRole('checkbox').click(); + await expect(page.getByText(/\d+ selected/)).toBeVisible(); + + await page.getByLabel('Clear selection').click(); + + await expect(page.getByText(/\d+ selected/)).not.toBeVisible(); +}); + +test('[BULK_ACTIONS]: can move multiple templates to a folder', async ({ page }) => { + const { sender, folder } = await seedBulkActionsTestRequirements(); + + await apiSignin({ + page, + email: sender.user.email, + redirectPath: `/t/${sender.team.url}/templates`, + }); + + await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click(); + await page.locator('tr', { hasText: 'Bulk Test Template 2' }).getByRole('checkbox').click(); + await page.getByRole('button', { name: 'Move to Folder' }).click(); + + await expect(page.getByRole('dialog')).toBeVisible(); + await expect(page.getByText('Move Templates to Folder')).toBeVisible(); + + await page.getByRole('button', { name: folder.name }).click(); + await page.getByRole('button', { name: 'Move' }).click(); + + await expectToastTextToBeVisible(page, 'Selected items have been moved.'); + + await page.goto(`/t/${sender.team.url}/templates/f/${folder.id}`); + await expect(page.getByRole('link', { name: 'Bulk Test Template 1' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Bulk Test Template 2' })).toBeVisible(); +}); + +test('[BULK_ACTIONS]: can delete multiple templates', async ({ page }) => { + const { sender } = await seedBulkActionsTestRequirements(); + + await apiSignin({ + page, + email: sender.user.email, + redirectPath: `/t/${sender.team.url}/templates`, + }); + + await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click(); + await page.locator('tr', { hasText: 'Bulk Test Template 2' }).getByRole('checkbox').click(); + + await page.getByRole('button', { name: 'Delete' }).click(); + + await expect(page.getByRole('dialog')).toBeVisible(); + await expect(page.getByText('Delete Templates')).toBeVisible(); + await expect(page.getByText('You are about to delete 2 templates')).toBeVisible(); + await expect(page.getByText('irreversible')).toBeVisible(); + + await page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click(); + + await expectToastTextToBeVisible(page, 'Templates deleted'); + + await expect(page.getByRole('link', { name: 'Bulk Test Template 1' })).not.toBeVisible(); + await expect(page.getByRole('link', { name: 'Bulk Test Template 2' })).not.toBeVisible(); + + await expect(page.getByRole('link', { name: 'Bulk Test Template 3' })).toBeVisible(); +}); + +test('[BULK_ACTIONS]: selection clears after successful move', async ({ page }) => { + const { sender, folder } = await seedBulkActionsTestRequirements(); + + await apiSignin({ + page, + email: sender.user.email, + redirectPath: `/t/${sender.team.url}/templates`, + }); + + await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click(); + await expect(page.getByText('1 selected')).toBeVisible(); + + await page.getByRole('button', { name: 'Move to Folder' }).click(); + await page.getByRole('button', { name: folder.name }).click(); + await page.getByRole('button', { name: 'Move' }).click(); + + await expectToastTextToBeVisible(page, 'Selected items have been moved.'); + await expect(page.getByText(/\d+ selected/)).not.toBeVisible(); +}); + +test('[BULK_ACTIONS]: selection clears after successful delete', async ({ page }) => { + const { sender } = await seedBulkActionsTestRequirements(); + + await apiSignin({ + page, + email: sender.user.email, + redirectPath: `/t/${sender.team.url}/templates`, + }); + + await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click(); + await expect(page.getByText('1 selected')).toBeVisible(); + + await page.getByRole('button', { name: 'Delete' }).click(); + await page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click(); + + await expectToastTextToBeVisible(page, 'Templates deleted'); + await expect(page.getByText(/\d+ selected/)).not.toBeVisible(); +}); + +test('[BULK_ACTIONS]: can search for folders in move dialog', async ({ page }) => { + const { sender, folder } = await seedBulkActionsTestRequirements(); + + const otherFolder = await seedBlankFolder(sender.user, sender.team.id, { + createFolderOptions: { + name: 'Other Template Folder', + teamId: sender.team.id, + type: FolderType.TEMPLATE, + }, + }); + + await apiSignin({ + page, + email: sender.user.email, + redirectPath: `/t/${sender.team.url}/templates`, + }); + + await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click(); + + await page.getByRole('button', { name: 'Move to Folder' }).click(); + await expect(page.getByRole('dialog')).toBeVisible(); + + await expect(page.getByRole('button', { name: folder.name })).toBeVisible(); + await expect(page.getByRole('button', { name: otherFolder.name })).toBeVisible(); + + await page.getByPlaceholder('Search folders...').fill('Target'); + await expect(page.getByRole('button', { name: folder.name })).toBeVisible(); + await expect(page.getByRole('button', { name: otherFolder.name })).not.toBeVisible(); + + await page.getByPlaceholder('Search folders...').fill('Other'); + await expect(page.getByRole('button', { name: folder.name })).not.toBeVisible(); + await expect(page.getByRole('button', { name: otherFolder.name })).toBeVisible(); + + await page.getByPlaceholder('Search folders...').fill('NonExistent'); + await expect(page.getByText('No folders found')).toBeVisible(); +}); + +test('[BULK_ACTIONS]: can move templates from folder to home (root)', async ({ page }) => { + const { sender, templates, folder } = await seedBulkActionsTestRequirements(); + + const { prisma } = await import('@documenso/prisma'); + + await prisma.envelope.updateMany({ + where: { id: templates[0].id }, + data: { folderId: folder.id }, + }); + + await apiSignin({ + page, + email: sender.user.email, + redirectPath: `/t/${sender.team.url}/templates/f/${folder.id}`, + }); + + await expect(page.getByRole('link', { name: 'Bulk Test Template 1' })).toBeVisible(); + + await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click(); + await expect(page.getByText('1 selected')).toBeVisible(); + + await page.getByRole('button', { name: 'Move to Folder' }).click(); + await expect(page.getByRole('dialog')).toBeVisible(); + + await page.getByRole('button', { name: 'Home (No Folder)' }).click(); + + await page.getByRole('button', { name: 'Move' }).click(); + + await expectToastTextToBeVisible(page, 'Selected items have been moved.'); + + await page.goto(`/t/${sender.team.url}/templates`); + await expect(page.getByRole('link', { name: 'Bulk Test Template 1' })).toBeVisible(); + + await page.goto(`/t/${sender.team.url}/templates/f/${folder.id}`); + await expect(page.getByRole('link', { name: 'Bulk Test Template 1' })).not.toBeVisible(); +}); diff --git a/packages/trpc/server/envelope-router/bulk-delete-envelopes.ts b/packages/trpc/server/envelope-router/bulk-delete-envelopes.ts new file mode 100644 index 000000000..6a8192d66 --- /dev/null +++ b/packages/trpc/server/envelope-router/bulk-delete-envelopes.ts @@ -0,0 +1,110 @@ +import { EnvelopeType } from '@prisma/client'; +import pMap from 'p-map'; + +import { deleteDocument } from '@documenso/lib/server-only/document/delete-document'; +import { getMultipleEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelopes-by-ids'; +import { deleteTemplate } from '@documenso/lib/server-only/template/delete-template'; +import { prisma } from '@documenso/prisma'; + +import { authenticatedProcedure } from '../trpc'; +import { + ZBulkDeleteEnvelopesRequestSchema, + ZBulkDeleteEnvelopesResponseSchema, +} from './bulk-delete-envelopes.types'; + +export const bulkDeleteEnvelopesRoute = authenticatedProcedure + // .meta(bulkDeleteEnvelopesMeta) // Keeping this as a private API for a little while until we're sure it's stable and the request/response schemas are finalized. + .input(ZBulkDeleteEnvelopesRequestSchema) + .output(ZBulkDeleteEnvelopesResponseSchema) + .mutation(async ({ input, ctx }) => { + const { teamId, user } = ctx; + const { envelopeIds } = input; + + ctx.logger.info({ + input: { + envelopeIds, + }, + }); + + const { envelopeWhereInput } = await getMultipleEnvelopeWhereInput({ + ids: { + type: 'envelopeId', + ids: envelopeIds, + }, + userId: user.id, + teamId, + type: null, + }); + + const envelopes = await prisma.envelope.findMany({ + where: envelopeWhereInput, + select: { + id: true, + type: true, + }, + }); + + const results = await pMap( + envelopes, + async (envelope) => { + const { id: envelopeId, type: envelopeType } = envelope; + + try { + if (envelopeType === EnvelopeType.DOCUMENT) { + await deleteDocument({ + id: { + type: 'envelopeId', + id: envelopeId, + }, + userId: user.id, + teamId, + requestMetadata: ctx.metadata, + }); + } else if (envelopeType === EnvelopeType.TEMPLATE) { + await deleteTemplate({ + id: { + type: 'envelopeId', + id: envelopeId, + }, + userId: user.id, + teamId, + }); + } + + return { + success: true, + envelopeId, + }; + } catch (err) { + ctx.logger.warn( + { + envelopeId, + error: err, + }, + 'Failed to delete envelope during bulk delete', + ); + + return { + success: false, + envelopeId, + }; + } + }, + { + concurrency: 10, + stopOnError: false, + }, + ); + + const deletedCount = results.filter((r) => r.success).length; + const failedIds = results.filter((r) => !r.success).map((r) => r.envelopeId); + + // Include envelope IDs that were not attempted (unauthorized/not found) + const attemptedIds = new Set(envelopes.map((e) => e.id)); + const unattemptedIds = envelopeIds.filter((id) => !attemptedIds.has(id)); + + return { + deletedCount, + failedIds: [...failedIds, ...unattemptedIds], + }; + }); diff --git a/packages/trpc/server/envelope-router/bulk-delete-envelopes.types.ts b/packages/trpc/server/envelope-router/bulk-delete-envelopes.types.ts new file mode 100644 index 000000000..1a75bc833 --- /dev/null +++ b/packages/trpc/server/envelope-router/bulk-delete-envelopes.types.ts @@ -0,0 +1,31 @@ +import { z } from 'zod'; + +// READ ME: IF YOU UNCOMMENT THIS THEN UNSKIP THE TEST IN api-access-envelope-bulk.spec.ts +// Keeping this as a private API for a little while until we're sure it's stable and the request/response schemas are finalized. +// export const bulkDeleteEnvelopesMeta: TrpcRouteMeta = { +// openapi: { +// method: 'POST', +// path: '/envelope/bulk/delete', +// summary: 'Bulk delete envelopes', +// description: 'Delete multiple envelopes.', +// tags: ['Envelopes'], +// }, +// }; + +export const ZBulkDeleteEnvelopesRequestSchema = z.object({ + envelopeIds: z + .array(z.string()) + .min(1) + .max(100) + .describe( + 'The IDs of the envelopes to delete. The maximum number of envelopes you can delete at once is 100.', + ), +}); + +export const ZBulkDeleteEnvelopesResponseSchema = z.object({ + deletedCount: z.number(), + failedIds: z.array(z.string()), +}); + +export type TBulkDeleteEnvelopesRequest = z.infer; +export type TBulkDeleteEnvelopesResponse = z.infer; diff --git a/packages/trpc/server/envelope-router/bulk-move-envelopes.ts b/packages/trpc/server/envelope-router/bulk-move-envelopes.ts new file mode 100644 index 000000000..6cf8594df --- /dev/null +++ b/packages/trpc/server/envelope-router/bulk-move-envelopes.ts @@ -0,0 +1,73 @@ +import { TEAM_DOCUMENT_VISIBILITY_MAP } from '@documenso/lib/constants/teams'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { getMultipleEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelopes-by-ids'; +import { buildTeamWhereQuery } from '@documenso/lib/utils/teams'; +import { prisma } from '@documenso/prisma'; + +import { authenticatedProcedure } from '../trpc'; +import { + ZBulkMoveEnvelopesRequestSchema, + ZBulkMoveEnvelopesResponseSchema, +} from './bulk-move-envelopes.types'; + +export const bulkMoveEnvelopesRoute = authenticatedProcedure + // .meta(bulkMoveEnvelopesMeta) + .input(ZBulkMoveEnvelopesRequestSchema) + .output(ZBulkMoveEnvelopesResponseSchema) + .mutation(async ({ input, ctx }) => { + const { teamId, user } = ctx; + const { envelopeIds, envelopeType, folderId } = input; + + ctx.logger.info({ + input: { + envelopeIds, + envelopeType, + folderId, + }, + }); + + // Build the where input for the update query. + const { envelopeWhereInput, team } = await getMultipleEnvelopeWhereInput({ + ids: { + type: 'envelopeId', + ids: envelopeIds, + }, + userId: user.id, + teamId, + type: envelopeType, + }); + + // Validate folder access if moving to a folder (not root). + if (folderId) { + const folder = await prisma.folder.findFirst({ + where: { + id: folderId, + team: buildTeamWhereQuery({ + teamId, + userId: user.id, + }), + type: envelopeType, + visibility: { + in: TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole], + }, + }, + }); + + if (!folder) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Folder not found or access denied', + }); + } + } + + const result = await prisma.envelope.updateMany({ + where: envelopeWhereInput, + data: { + folderId: folderId, + }, + }); + + return { + movedCount: result.count, + }; + }); diff --git a/packages/trpc/server/envelope-router/bulk-move-envelopes.types.ts b/packages/trpc/server/envelope-router/bulk-move-envelopes.types.ts new file mode 100644 index 000000000..7c62a0bd3 --- /dev/null +++ b/packages/trpc/server/envelope-router/bulk-move-envelopes.types.ts @@ -0,0 +1,38 @@ +import { EnvelopeType } from '@prisma/client'; +import { z } from 'zod'; + +// READ ME: IF YOU UNCOMMENT THIS THEN UNSKIP THE TEST IN api-access-envelope-bulk.spec.ts +// Keeping this as a private API for a little while until we're sure it's stable and the request/response schemas are finalized. +// export const bulkMoveEnvelopesMeta: TrpcRouteMeta = { +// openapi: { +// method: 'POST', +// path: '/envelope/bulk/move', +// summary: 'Bulk move envelopes to folder', +// description: 'Move multiple envelopes to a specified folder.', +// tags: ['Envelopes'], +// }, +// }; + +export const ZBulkMoveEnvelopesRequestSchema = z.object({ + envelopeIds: z + .array(z.string()) + .min(1) + .max(100) + .describe( + 'The IDs of the envelopes to move. The maximum number of envelopes you can move at once is 100.', + ), + envelopeType: z.nativeEnum(EnvelopeType).describe('The type of the envelopes being moved.'), + folderId: z + .string() + .nullable() + .describe( + 'The ID of the folder to move the envelopes to. If null envelopes will be moved to the root folder.', + ), +}); + +export const ZBulkMoveEnvelopesResponseSchema = z.object({ + movedCount: z.number().describe('The number of envelopes that were moved.'), +}); + +export type TBulkMoveEnvelopesRequest = z.infer; +export type TBulkMoveEnvelopesResponse = z.infer; diff --git a/packages/trpc/server/envelope-router/router.ts b/packages/trpc/server/envelope-router/router.ts index 78131f093..68369b118 100644 --- a/packages/trpc/server/envelope-router/router.ts +++ b/packages/trpc/server/envelope-router/router.ts @@ -3,6 +3,8 @@ import { createAttachmentRoute } from './attachment/create-attachment'; import { deleteAttachmentRoute } from './attachment/delete-attachment'; import { findAttachmentsRoute } from './attachment/find-attachments'; import { updateAttachmentRoute } from './attachment/update-attachment'; +import { bulkDeleteEnvelopesRoute } from './bulk-delete-envelopes'; +import { bulkMoveEnvelopesRoute } from './bulk-move-envelopes'; import { createEnvelopeRoute } from './create-envelope'; import { createEnvelopeItemsRoute } from './create-envelope-items'; import { deleteEnvelopeRoute } from './delete-envelope'; @@ -72,6 +74,10 @@ export const envelopeRouter = router({ auditLog: { find: findEnvelopeAuditLogsRoute, }, + bulk: { + move: bulkMoveEnvelopesRoute, + delete: bulkDeleteEnvelopesRoute, + }, get: getEnvelopeRoute, getMany: getEnvelopesByIdsRoute, create: createEnvelopeRoute, diff --git a/packages/ui/primitives/data-table.tsx b/packages/ui/primitives/data-table.tsx index 22a2bf12a..140293755 100644 --- a/packages/ui/primitives/data-table.tsx +++ b/packages/ui/primitives/data-table.tsx @@ -4,6 +4,7 @@ import { Trans } from '@lingui/react/macro'; import type { ColumnDef, PaginationState, + RowSelectionState, Table as TTable, Updater, VisibilityState, @@ -15,7 +16,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '. export type DataTableChildren = (_table: TTable) => React.ReactNode; -export type { ColumnDef as DataTableColumnDef } from '@tanstack/react-table'; +export type { ColumnDef as DataTableColumnDef, RowSelectionState } from '@tanstack/react-table'; export interface DataTableProps { columns: ColumnDef[]; @@ -40,6 +41,10 @@ export interface DataTableProps { enable: boolean; component?: React.ReactNode; }; + enableRowSelection?: boolean; + rowSelection?: RowSelectionState; + onRowSelectionChange?: (selection: RowSelectionState) => void; + getRowId?: (row: TData) => string; } export function DataTable({ @@ -58,6 +63,10 @@ export function DataTable({ rowClassName, children, emptyState, + enableRowSelection, + rowSelection, + onRowSelectionChange, + getRowId, }: DataTableProps) { const pagination = useMemo(() => { if (currentPage !== undefined && perPage !== undefined) { @@ -85,6 +94,13 @@ export function DataTable({ } }; + const onTableRowSelectionChange = (updater: Updater) => { + if (onRowSelectionChange) { + const newSelection = typeof updater === 'function' ? updater(rowSelection ?? {}) : updater; + onRowSelectionChange(newSelection); + } + }; + const table = useReactTable({ data, columns, @@ -92,10 +108,14 @@ export function DataTable({ state: { pagination: manualPagination ? pagination : undefined, columnVisibility, + rowSelection: rowSelection ?? {}, }, manualPagination, pageCount: totalPages, onPaginationChange: onTablePaginationChange, + enableRowSelection, + onRowSelectionChange: onTableRowSelectionChange, + getRowId, }); return ( @@ -162,7 +182,7 @@ export function DataTable({ {hasFilters && onClearFilters !== undefined && (