mirror of
https://github.com/documenso/documenso.git
synced 2026-07-26 09:54:51 +10:00
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user