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:
Lucas Smith
2026-06-18 13:52:35 +10:00
committed by GitHub
parent d5ce222482
commit 4f346d3c2d
45 changed files with 1806 additions and 45 deletions
@@ -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,