mirror of
https://github.com/documenso/documenso.git
synced 2026-07-26 01:45:08 +10:00
feat: add cancellable document status (#2992)
Adds a CANCELLED envelope status that privileged members (owner or team admin/manager) can move a pending document into. Sending recipient notifications via a background job while retaining the document in the dashboard as proof of distribution. Includes a dedicated Cancelled tab, single and bulk cancel actions, the ENVELOPE_CANCELLED mutability guard, and e2e coverage for permissions and visibility.
This commit is contained in:
@@ -3,7 +3,7 @@ import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelop
|
||||
import { getPresignGetUrl } from '@documenso/lib/universal/upload/server-actions';
|
||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||
import type { DocumentData } from '@prisma/client';
|
||||
import { DocumentDataType, EnvelopeType } from '@prisma/client';
|
||||
import { DocumentDataType, DocumentStatus, EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
import {
|
||||
@@ -58,7 +58,12 @@ export const downloadDocumentBetaRoute = authenticatedProcedure
|
||||
});
|
||||
}
|
||||
|
||||
if (version === 'signed' && !isDocumentCompleted(envelope.status)) {
|
||||
// A cancelled document was never sealed, so its data is the unsigned original.
|
||||
// Treat it as not-completed here so a "signed" version is never served for it.
|
||||
// REJECTED and COMPLETED keep their prior behavior.
|
||||
const hasSignedArtifact = isDocumentCompleted(envelope.status) && envelope.status !== DocumentStatus.CANCELLED;
|
||||
|
||||
if (version === 'signed' && !hasSignedArtifact) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Document is not completed yet.',
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-e
|
||||
import { generateCertificatePdf } from '@documenso/lib/server-only/pdf/generate-certificate-pdf';
|
||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
import { DocumentStatus, EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
import {
|
||||
@@ -60,7 +60,9 @@ export const downloadDocumentCertificateRoute = authenticatedProcedure
|
||||
});
|
||||
}
|
||||
|
||||
if (!isDocumentCompleted(envelope.status)) {
|
||||
// A cancelled document was never sealed/completed, so a signing certificate
|
||||
// must not be generated for it. REJECTED and COMPLETED keep their prior behavior.
|
||||
if (!isDocumentCompleted(envelope.status) || envelope.status === DocumentStatus.CANCELLED) {
|
||||
throw new AppError('DOCUMENT_NOT_COMPLETE');
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ export const ZFindDocumentsInternalResponseSchema = ZFindResultResponse.extend({
|
||||
[ExtendedDocumentStatus.PENDING]: z.number(),
|
||||
[ExtendedDocumentStatus.COMPLETED]: z.number(),
|
||||
[ExtendedDocumentStatus.REJECTED]: z.number(),
|
||||
[ExtendedDocumentStatus.CANCELLED]: z.number(),
|
||||
[ExtendedDocumentStatus.INBOX]: z.number(),
|
||||
[ExtendedDocumentStatus.ALL]: z.number(),
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { cancelDocument } from '@documenso/lib/server-only/document/cancel-document';
|
||||
import { getMultipleEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelopes-by-ids';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
import pMap from 'p-map';
|
||||
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
import { ZBulkCancelEnvelopesRequestSchema, ZBulkCancelEnvelopesResponseSchema } from './bulk-cancel-envelopes.types';
|
||||
|
||||
export const bulkCancelEnvelopesRoute = authenticatedProcedure
|
||||
.input(ZBulkCancelEnvelopesRequestSchema)
|
||||
.output(ZBulkCancelEnvelopesResponseSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { teamId, user } = ctx;
|
||||
const { envelopeIds, reason } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
envelopeIds,
|
||||
},
|
||||
});
|
||||
|
||||
// Cancelling only applies to documents. Templates are filtered out here so
|
||||
// selecting a template never counts towards a cancellation.
|
||||
const { envelopeWhereInput } = await getMultipleEnvelopeWhereInput({
|
||||
ids: {
|
||||
type: 'envelopeId',
|
||||
ids: envelopeIds,
|
||||
},
|
||||
userId: user.id,
|
||||
teamId,
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
});
|
||||
|
||||
const envelopes = await prisma.envelope.findMany({
|
||||
where: envelopeWhereInput,
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
const results = await pMap(
|
||||
envelopes,
|
||||
async (envelope) => {
|
||||
const { id: envelopeId } = envelope;
|
||||
|
||||
try {
|
||||
await cancelDocument({
|
||||
id: {
|
||||
type: 'envelopeId',
|
||||
id: envelopeId,
|
||||
},
|
||||
userId: user.id,
|
||||
teamId,
|
||||
reason,
|
||||
requestMetadata: ctx.metadata,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
envelopeId,
|
||||
};
|
||||
} catch (err) {
|
||||
ctx.logger.warn(
|
||||
{
|
||||
envelopeId,
|
||||
error: err,
|
||||
},
|
||||
'Failed to cancel envelope during bulk cancel',
|
||||
);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
envelopeId,
|
||||
};
|
||||
}
|
||||
},
|
||||
{
|
||||
concurrency: 10,
|
||||
stopOnError: false,
|
||||
},
|
||||
);
|
||||
|
||||
const cancelledCount = 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/not a document).
|
||||
const attemptedIds = new Set(envelopes.map((e) => e.id));
|
||||
const unattemptedIds = envelopeIds.filter((id) => !attemptedIds.has(id));
|
||||
|
||||
return {
|
||||
cancelledCount,
|
||||
failedIds: [...failedIds, ...unattemptedIds],
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// 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 ZBulkCancelEnvelopesRequestSchema = z.object({
|
||||
envelopeIds: z
|
||||
.array(z.string())
|
||||
.min(1)
|
||||
.max(100)
|
||||
.describe('The IDs of the envelopes to cancel. The maximum number of envelopes you can cancel at once is 100.'),
|
||||
reason: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ZBulkCancelEnvelopesResponseSchema = z.object({
|
||||
cancelledCount: z.number(),
|
||||
failedIds: z.array(z.string()),
|
||||
});
|
||||
|
||||
export type TBulkCancelEnvelopesRequest = z.infer<typeof ZBulkCancelEnvelopesRequestSchema>;
|
||||
export type TBulkCancelEnvelopesResponse = z.infer<typeof ZBulkCancelEnvelopesResponseSchema>;
|
||||
@@ -0,0 +1,37 @@
|
||||
import { cancelDocument } from '@documenso/lib/server-only/document/cancel-document';
|
||||
|
||||
import { ZGenericSuccessResponse } from '../schema';
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
import {
|
||||
cancelEnvelopeMeta,
|
||||
ZCancelEnvelopeRequestSchema,
|
||||
ZCancelEnvelopeResponseSchema,
|
||||
} from './cancel-envelope.types';
|
||||
|
||||
export const cancelEnvelopeRoute = authenticatedProcedure
|
||||
.meta(cancelEnvelopeMeta)
|
||||
.input(ZCancelEnvelopeRequestSchema)
|
||||
.output(ZCancelEnvelopeResponseSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { teamId } = ctx;
|
||||
const { envelopeId, reason } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
envelopeId,
|
||||
},
|
||||
});
|
||||
|
||||
await cancelDocument({
|
||||
id: {
|
||||
type: 'envelopeId',
|
||||
id: envelopeId,
|
||||
},
|
||||
userId: ctx.user.id,
|
||||
teamId,
|
||||
reason,
|
||||
requestMetadata: ctx.metadata,
|
||||
});
|
||||
|
||||
return ZGenericSuccessResponse;
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZSuccessResponseSchema } from '../schema';
|
||||
import type { TrpcRouteMeta } from '../trpc';
|
||||
|
||||
export const cancelEnvelopeMeta: TrpcRouteMeta = {
|
||||
openapi: {
|
||||
method: 'POST',
|
||||
path: '/envelope/cancel',
|
||||
summary: 'Cancel envelope',
|
||||
tags: ['Envelope'],
|
||||
},
|
||||
};
|
||||
|
||||
export const ZCancelEnvelopeRequestSchema = z.object({
|
||||
envelopeId: z.string(),
|
||||
reason: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ZCancelEnvelopeResponseSchema = ZSuccessResponseSchema;
|
||||
|
||||
export type TCancelEnvelopeRequest = z.infer<typeof ZCancelEnvelopeRequestSchema>;
|
||||
export type TCancelEnvelopeResponse = z.infer<typeof ZCancelEnvelopeResponseSchema>;
|
||||
@@ -3,8 +3,10 @@ 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 { bulkCancelEnvelopesRoute } from './bulk-cancel-envelopes';
|
||||
import { bulkDeleteEnvelopesRoute } from './bulk-delete-envelopes';
|
||||
import { bulkMoveEnvelopesRoute } from './bulk-move-envelopes';
|
||||
import { cancelEnvelopeRoute } from './cancel-envelope';
|
||||
import { createEnvelopeRoute } from './create-envelope';
|
||||
import { createEnvelopeItemsRoute } from './create-envelope-items';
|
||||
import { deleteEnvelopeRoute } from './delete-envelope';
|
||||
@@ -85,6 +87,7 @@ export const envelopeRouter = router({
|
||||
bulk: {
|
||||
move: bulkMoveEnvelopesRoute,
|
||||
delete: bulkDeleteEnvelopesRoute,
|
||||
cancel: bulkCancelEnvelopesRoute,
|
||||
},
|
||||
editor: {
|
||||
get: getEditorEnvelopeRoute,
|
||||
@@ -95,6 +98,7 @@ export const envelopeRouter = router({
|
||||
use: useEnvelopeRoute,
|
||||
update: updateEnvelopeRoute,
|
||||
delete: deleteEnvelopeRoute,
|
||||
cancel: cancelEnvelopeRoute,
|
||||
duplicate: duplicateEnvelopeRoute,
|
||||
saveAsTemplate: saveAsTemplateRoute,
|
||||
distribute: distributeEnvelopeRoute,
|
||||
|
||||
Reference in New Issue
Block a user