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
@@ -1,4 +1,6 @@
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import { generatePartialSignedPdf } from '@documenso/lib/server-only/pdf/generate-partial-signed-pdf';
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
import { sha256 } from '@documenso/lib/universal/crypto';
@@ -26,6 +28,29 @@ type DocumentDataInput = {
initialData: string;
};
export const resolveFileUploadUserId = async (c: Context<HonoEnv>): Promise<number | null> => {
const session = await getOptionalSession(c);
if (session.user?.id) {
return session.user.id;
}
const authorizationHeader = c.req.header('authorization');
const [bearerToken] = (authorizationHeader || '').split('Bearer ').filter((part) => part.length > 0);
const queryToken = c.req.query('token');
const presignToken = bearerToken || queryToken;
if (!presignToken) {
return null;
}
const verifiedToken = await verifyEmbeddingPresignToken({ token: presignToken }).catch(() => undefined);
return verifiedToken?.userId ?? null;
};
type EnvelopeForPendingDownload = {
id: string;
status: DocumentStatus;
+19 -2
View File
@@ -10,8 +10,9 @@ import type { Prisma } from '@prisma/client';
import { Hono } from 'hono';
import type { HonoEnv } from '../../router';
import { checkEnvelopeFileAccess, handleEnvelopeItemFileRequest } from './files.helpers';
import { checkEnvelopeFileAccess, handleEnvelopeItemFileRequest, resolveFileUploadUserId } from './files.helpers';
import {
isAllowedUploadContentType,
type TGetPresignedPostUrlResponse,
ZGetEnvelopeItemFileDownloadRequestParamsSchema,
ZGetEnvelopeItemFileRequestParamsSchema,
@@ -31,6 +32,12 @@ export const filesRoute = new Hono<HonoEnv>()
*/
.post('/upload-pdf', sValidator('form', ZUploadPdfRequestSchema), async (c) => {
try {
const userId = await resolveFileUploadUserId(c);
if (!userId) {
return c.json({ error: 'Unauthorized' }, 401);
}
const { file } = c.req.valid('form');
if (!file) {
@@ -55,10 +62,20 @@ export const filesRoute = new Hono<HonoEnv>()
}
})
.post('/presigned-post-url', sValidator('json', ZGetPresignedPostUrlRequestSchema), async (c) => {
const userId = await resolveFileUploadUserId(c);
if (!userId) {
return c.json({ error: 'Unauthorized' }, 401);
}
const { fileName, contentType } = c.req.valid('json');
if (!isAllowedUploadContentType(contentType)) {
return c.json({ error: 'Unsupported content type' }, 400);
}
try {
const { key, url } = await getPresignPostUrl(fileName, contentType);
const { key, url } = await getPresignPostUrl(fileName, contentType, userId);
return c.json({ key, url } satisfies TGetPresignedPostUrlResponse);
} catch (err) {
@@ -13,6 +13,14 @@ export const ZUploadPdfResponseSchema = DocumentDataSchema.pick({
export type TUploadPdfRequest = z.infer<typeof ZUploadPdfRequestSchema>;
export type TUploadPdfResponse = z.infer<typeof ZUploadPdfResponseSchema>;
export const ALLOWED_UPLOAD_CONTENT_TYPES = ['application/pdf', 'image/jpeg', 'image/png', 'image/webp'] as const;
export const isAllowedUploadContentType = (contentType: string): boolean => {
const normalizedContentType = contentType.split(';').at(0)?.trim().toLowerCase();
return ALLOWED_UPLOAD_CONTENT_TYPES.some((allowed) => allowed === normalizedContentType);
};
export const ZGetPresignedPostUrlRequestSchema = z.object({
fileName: z.string().min(1),
contentType: z.string().min(1),