feat: add bulk document selection and move functionality (#2387)

This PR introduces bulk actions for documents, allowing users to select
multiple envelopes and perform actions such as moving or deleting 1 or
more documents simultaneously.
This commit is contained in:
Catalin Pit
2026-01-28 09:27:32 +02:00
committed by GitHub
parent 28bc2dc975
commit 155310b028
18 changed files with 2068 additions and 266 deletions
@@ -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],
};
});
@@ -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<typeof ZBulkDeleteEnvelopesRequestSchema>;
export type TBulkDeleteEnvelopesResponse = z.infer<typeof ZBulkDeleteEnvelopesResponseSchema>;
@@ -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,
};
});
@@ -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<typeof ZBulkMoveEnvelopesRequestSchema>;
export type TBulkMoveEnvelopesResponse = z.infer<typeof ZBulkMoveEnvelopesResponseSchema>;
@@ -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,