Merge branch 'main' into fix/cc-recipient-order-last

This commit is contained in:
Catalin Pit
2026-06-19 15:33:06 +03:00
committed by GitHub
50 changed files with 1824 additions and 50 deletions
+3
View File
@@ -23,6 +23,9 @@ export const DOCUMENT_STATUS: {
[DocumentStatus.REJECTED]: {
description: msg`Rejected`,
},
[DocumentStatus.CANCELLED]: {
description: msg`Cancelled`,
},
[DocumentStatus.DRAFT]: {
description: msg`Draft`,
},
+3
View File
@@ -27,6 +27,7 @@ export enum AppErrorCode {
ENVELOPE_DRAFT = 'ENVELOPE_DRAFT',
ENVELOPE_COMPLETED = 'ENVELOPE_COMPLETED',
ENVELOPE_REJECTED = 'ENVELOPE_REJECTED',
ENVELOPE_CANCELLED = 'ENVELOPE_CANCELLED',
ENVELOPE_LEGACY = 'ENVELOPE_LEGACY',
/**
* Authoring mutation rejected because the envelope is an AES/QES envelope
@@ -80,6 +81,7 @@ export const genericErrorCodeToTrpcErrorCodeMap: Record<string, { code: string;
[AppErrorCode.ENVELOPE_DRAFT]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.ENVELOPE_COMPLETED]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.ENVELOPE_REJECTED]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.ENVELOPE_CANCELLED]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.ENVELOPE_LEGACY]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.ENVELOPE_TSP_LOCKED]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.CSC_INSTANCE_MODE_MISMATCH]: { code: 'BAD_REQUEST', status: 400 },
@@ -286,6 +288,7 @@ export class AppError extends Error {
AppErrorCode.ENVELOPE_DRAFT,
AppErrorCode.ENVELOPE_COMPLETED,
AppErrorCode.ENVELOPE_REJECTED,
AppErrorCode.ENVELOPE_CANCELLED,
AppErrorCode.ENVELOPE_LEGACY,
AppErrorCode.ENVELOPE_TSP_LOCKED,
AppErrorCode.CSC_INSTANCE_MODE_MISMATCH,
@@ -18,6 +18,7 @@ export const getDocumentStats = async () => {
[ExtendedDocumentStatus.PENDING]: 0,
[ExtendedDocumentStatus.COMPLETED]: 0,
[ExtendedDocumentStatus.REJECTED]: 0,
[ExtendedDocumentStatus.CANCELLED]: 0,
[ExtendedDocumentStatus.ALL]: 0,
};
@@ -0,0 +1,129 @@
import { jobs } from '@documenso/lib/jobs/client';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, EnvelopeType, WebhookTriggerEvents } from '@prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../types/webhook-payload';
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { mapSecondaryIdToDocumentId, unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
import { isMemberManagerOrAbove } from '../../utils/teams';
import { getMemberRoles } from '../team/get-member-roles';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export type CancelDocumentOptions = {
id: EnvelopeIdOptions;
userId: number;
teamId: number;
reason?: string;
requestMetadata: ApiRequestMetadata;
};
export const cancelDocument = async ({ id, userId, teamId, reason, requestMetadata }: CancelDocumentOptions) => {
// Note: This is an unsafe request, we validate the ownership/permission later in the function.
const envelope = await prisma.envelope.findUnique({
where: unsafeBuildEnvelopeIdQuery(id, EnvelopeType.DOCUMENT),
include: {
recipients: true,
documentMeta: true,
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
});
}
const isUserOwner = envelope.userId === userId;
const teamRole = await getMemberRoles({
teamId: envelope.teamId,
reference: {
type: 'User',
id: userId,
},
})
.then((roles) => roles.teamRole)
.catch(() => null);
const isUserTeamMember = teamRole !== null;
// Callers with no relationship to the document must not be able to determine
// whether it exists, so respond as if it was not found.
if (!isUserOwner && !isUserTeamMember) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
});
}
const isPrivilegedTeamMember = teamRole && isMemberManagerOrAbove(teamRole);
// The document is visible to the caller, but cancelling requires elevated permissions.
if (!isUserOwner && !isPrivilegedTeamMember) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Not allowed',
});
}
if (envelope.status !== DocumentStatus.PENDING) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Only pending documents can be cancelled',
});
}
const updatedEnvelope = await prisma.$transaction(async (tx) => {
const updated = await tx.envelope.update({
where: {
id: envelope.id,
},
data: {
status: DocumentStatus.CANCELLED,
completedAt: new Date(),
},
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
envelopeId: envelope.id,
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CANCELLED,
metadata: requestMetadata,
data: {
reason,
},
}),
});
return updated;
});
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
// Send cancellation emails to recipients via the resilient background job.
await jobs.triggerJob({
name: 'send.document.cancelled.emails',
payload: {
documentId: legacyDocumentId,
cancellationReason: reason,
requestMetadata: requestMetadata.requestMetadata,
},
});
// Trigger the webhook with the updated (cancelled) envelope payload.
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_CANCELLED,
data: ZWebhookDocumentSchema.parse(
mapEnvelopeToWebhookDocumentPayload({
...envelope,
status: updatedEnvelope.status,
completedAt: updatedEnvelope.completedAt,
}),
),
userId,
teamId,
});
return updatedEnvelope;
};
@@ -220,7 +220,11 @@ export const findDocuments = async ({
eb.or([
eb('Envelope.userId', '=', user.id),
eb.and([
eb('Envelope.status', 'in', [sql.lit(DocumentStatus.COMPLETED), sql.lit(DocumentStatus.PENDING)]),
eb('Envelope.status', 'in', [
sql.lit(DocumentStatus.COMPLETED),
sql.lit(DocumentStatus.PENDING),
sql.lit(DocumentStatus.CANCELLED),
]),
recipientExists(eb, user.email),
]),
]),
@@ -291,6 +295,16 @@ export const findDocuments = async ({
]),
),
)
.with(ExtendedDocumentStatus.CANCELLED, () =>
qb
.where('Envelope.status', '=', sql.lit(ExtendedDocumentStatus.CANCELLED))
.where((eb) =>
eb.and([
personalDeletedFilter(eb),
eb.or([eb('Envelope.userId', '=', user.id), recipientExists(eb, user.email)]),
]),
),
)
.exhaustive();
};
@@ -429,6 +443,18 @@ export const findDocuments = async ({
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
}),
)
.with(ExtendedDocumentStatus.CANCELLED, () =>
qb.where('Envelope.status', '=', sql.lit(ExtendedDocumentStatus.CANCELLED)).where((eb) => {
const accessBranches = [eb('Envelope.teamId', '=', teamData.id)];
if (teamEmail) {
accessBranches.push(senderEmailIs(eb, teamEmail));
accessBranches.push(recipientExists(eb, teamEmail));
}
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
}),
)
.exhaustive();
};
+18 -2
View File
@@ -239,6 +239,20 @@ export const getStats = async ({ userId, teamId, period, search = '', folderId,
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
});
// CANCELLED: team-owned cancelled + team-email received cancelled docs
const cancelledQuery = buildBaseQuery()
.where('Envelope.status', '=', sql.lit(DocumentStatus.CANCELLED))
.where((eb) => {
const accessBranches = [eb('Envelope.teamId', '=', team.id)];
if (teamEmail) {
accessBranches.push(senderEmailIs(eb, teamEmail));
accessBranches.push(recipientExists(eb, teamEmail));
}
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
});
// INBOX: non-draft docs where team email is a NOT_SIGNED, non-CC recipient
// Returns 0 if the team has no team email.
const inboxQuery = teamEmail
@@ -260,21 +274,23 @@ export const getStats = async ({ userId, teamId, period, search = '', folderId,
// ─── Execute all counts in parallel ──────────────────────────────────
const [draft, pending, completed, rejected, inbox] = await Promise.all([
const [draft, pending, completed, rejected, cancelled, inbox] = await Promise.all([
cappedCount(draftQuery),
cappedCount(pendingQuery),
cappedCount(completedQuery),
cappedCount(rejectedQuery),
cappedCount(cancelledQuery),
inboxQuery ? cappedCount(inboxQuery) : Promise.resolve(0),
]);
const all = Math.min(draft + pending + completed + rejected + inbox, STATS_COUNT_CAP);
const all = Math.min(draft + pending + completed + rejected + cancelled + inbox, STATS_COUNT_CAP);
const stats: Record<ExtendedDocumentStatus, number> = {
[ExtendedDocumentStatus.DRAFT]: draft,
[ExtendedDocumentStatus.PENDING]: pending,
[ExtendedDocumentStatus.COMPLETED]: completed,
[ExtendedDocumentStatus.REJECTED]: rejected,
[ExtendedDocumentStatus.CANCELLED]: cancelled,
[ExtendedDocumentStatus.INBOX]: inbox,
[ExtendedDocumentStatus.ALL]: all,
};
@@ -34,8 +34,9 @@ type EnvelopeIdRef = Pick<Envelope, 'id'>;
* Throws:
* - `ENVELOPE_TSP_LOCKED` when the envelope is PENDING (the case unique to
* the TSP lock — SES routes happily allow PENDING).
* - `ENVELOPE_COMPLETED` / `ENVELOPE_REJECTED` for those terminal states, to
* stay consistent with the existing envelope-state error vocabulary.
* - `ENVELOPE_COMPLETED` / `ENVELOPE_REJECTED` / `ENVELOPE_CANCELLED` for those
* terminal states, to stay consistent with the existing envelope-state error
* vocabulary.
*/
export function assertEnvelopeMutable(envelope: EnvelopeMutableSnapshot): Promise<void>;
export function assertEnvelopeMutable(envelope: EnvelopeIdRef, tx: Prisma.TransactionClient): Promise<void>;
@@ -73,6 +74,7 @@ const assertSnapshotMutable = (envelope: EnvelopeMutableSnapshot): void => {
.with(DocumentStatus.PENDING, () => AppErrorCode.ENVELOPE_TSP_LOCKED)
.with(DocumentStatus.COMPLETED, () => AppErrorCode.ENVELOPE_COMPLETED)
.with(DocumentStatus.REJECTED, () => AppErrorCode.ENVELOPE_REJECTED)
.with(DocumentStatus.CANCELLED, () => AppErrorCode.ENVELOPE_CANCELLED)
.otherwise(() => AppErrorCode.INVALID_REQUEST);
throw new AppError(errorCode, {
@@ -17,6 +17,7 @@ export const generateSampleWebhookPayload = (event: WebhookTriggerEvents, webhoo
const now = new Date();
const basePayload = {
id: 10,
envelopeId: 'env_123',
externalId: null,
userId: 1,
authOptions: null,
@@ -52,6 +53,7 @@ export const generateSampleWebhookPayload = (event: WebhookTriggerEvents, webhoo
recipients: [
{
id: 52,
envelopeId: 'env_123',
documentId: 10,
templateId: null,
email: 'signer@documenso.com',
@@ -73,6 +75,7 @@ export const generateSampleWebhookPayload = (event: WebhookTriggerEvents, webhoo
Recipient: [
{
id: 52,
envelopeId: 'env_123',
documentId: 10,
templateId: null,
email: 'signer@documenso.com',
@@ -269,6 +272,7 @@ export const generateSampleWebhookPayload = (event: WebhookTriggerEvents, webhoo
recipients: [
{
id: 50,
envelopeId: 'env_123',
documentId: 10,
templateId: null,
email: 'signer2@documenso.com',
@@ -291,6 +295,7 @@ export const generateSampleWebhookPayload = (event: WebhookTriggerEvents, webhoo
},
{
id: 51,
envelopeId: 'env_123',
documentId: 10,
templateId: null,
email: 'signer1@documenso.com',
@@ -315,6 +320,7 @@ export const generateSampleWebhookPayload = (event: WebhookTriggerEvents, webhoo
Recipient: [
{
id: 50,
envelopeId: 'env_123',
documentId: 10,
templateId: null,
email: 'signer2@documenso.com',
@@ -337,6 +343,7 @@ export const generateSampleWebhookPayload = (event: WebhookTriggerEvents, webhoo
},
{
id: 51,
envelopeId: 'env_123',
documentId: 10,
templateId: null,
email: 'signer1@documenso.com',
@@ -444,6 +451,7 @@ export const generateSampleWebhookPayload = (event: WebhookTriggerEvents, webhoo
recipients: [
{
id: 7,
envelopeId: 'env_123',
documentId: 7,
templateId: null,
email: 'signer1@documenso.com',
@@ -468,6 +476,7 @@ export const generateSampleWebhookPayload = (event: WebhookTriggerEvents, webhoo
Recipient: [
{
id: 7,
envelopeId: 'env_123',
documentId: 7,
templateId: null,
email: 'signer@documenso.com',
+12
View File
@@ -31,6 +31,7 @@ export const ZDocumentAuditLogTypeSchema = z.enum([
'DOCUMENT_COMPLETED', // When the document is sealed and fully completed.
'DOCUMENT_CREATED', // When the document is created.
'DOCUMENT_DELETED', // When the document is soft deleted.
'DOCUMENT_CANCELLED', // When a privileged member cancels the document.
'DOCUMENT_FIELDS_AUTO_INSERTED', // When a field is auto inserted during send due to default values (radio/dropdown/checkbox).
'DOCUMENT_FIELD_INSERTED', // When a field is inserted (signed/approved/etc) by a recipient.
'DOCUMENT_FIELD_UNINSERTED', // When a field is uninserted by a recipient.
@@ -296,6 +297,16 @@ export const ZDocumentAuditLogEventDocumentDeletedSchema = z.object({
}),
});
/**
* Event: Document cancelled.
*/
export const ZDocumentAuditLogEventDocumentCancelledSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CANCELLED),
data: z.object({
reason: z.string().optional(),
}),
});
/**
* Event: Document field inserted.
*/
@@ -815,6 +826,7 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
ZDocumentAuditLogEventDocumentCompletedSchema,
ZDocumentAuditLogEventDocumentCreatedSchema,
ZDocumentAuditLogEventDocumentDeletedSchema,
ZDocumentAuditLogEventDocumentCancelledSchema,
ZDocumentAuditLogEventDocumentMovedToTeamSchema,
ZDocumentAuditLogEventDocumentDelegatedOwnerCreatedSchema,
ZDocumentAuditLogEventDocumentFieldsAutoInsertedSchema,
+4
View File
@@ -21,6 +21,7 @@ import { mapSecondaryIdToDocumentId, mapSecondaryIdToTemplateId } from '../utils
*/
export const ZWebhookRecipientSchema = z.object({
id: z.number(),
envelopeId: z.string(),
documentId: z.number().nullable(),
templateId: z.number().nullable(),
email: z.string(),
@@ -64,6 +65,7 @@ export const ZWebhookDocumentMetaSchema = z.object({
*/
export const ZWebhookDocumentSchema = z.object({
id: z.number(),
envelopeId: z.string(),
externalId: z.string().nullable(),
userId: z.number(),
authOptions: z.any().nullable(),
@@ -117,6 +119,7 @@ export const mapEnvelopeToWebhookDocumentPayload = (
const mappedRecipients = rawRecipients.map((recipient) => ({
id: recipient.id,
envelopeId: envelope.id,
documentId: envelope.type === EnvelopeType.DOCUMENT ? legacyId : null,
templateId: envelope.type === EnvelopeType.TEMPLATE ? legacyId : null,
email: recipient.email,
@@ -137,6 +140,7 @@ export const mapEnvelopeToWebhookDocumentPayload = (
return {
id: legacyId,
envelopeId: envelope.id,
externalId: envelope.externalId,
userId: envelope.userId,
authOptions: envelope.authOptions,
+27 -5
View File
@@ -13,7 +13,27 @@ type File = {
arrayBuffer: () => Promise<ArrayBuffer>;
};
export const putPdfFile = async (file: File) => {
/**
* Options for uploads that are not authorized by a logged-in session.
*
* Embedded authoring flows run cross-origin without a session cookie, so they
* must authorize uploads with their embedding presign token instead.
*/
export type PutFileOptions = {
presignToken?: string;
};
const buildUploadAuthHeaders = (options?: PutFileOptions): Record<string, string> => {
if (!options?.presignToken) {
return {};
}
return {
Authorization: `Bearer ${options.presignToken}`,
};
};
export const putPdfFile = async (file: File, options?: PutFileOptions) => {
const formData = new FormData();
// Create a proper File object from the data
@@ -25,6 +45,7 @@ export const putPdfFile = async (file: File) => {
const response = await fetch('/api/files/upload-pdf', {
method: 'POST',
headers: buildUploadAuthHeaders(options),
body: formData,
});
@@ -41,12 +62,12 @@ export const putPdfFile = async (file: File) => {
/**
* Uploads a file to the appropriate storage location.
*/
export const putFile = async (file: File) => {
export const putFile = async (file: File, options?: PutFileOptions) => {
const NEXT_PUBLIC_UPLOAD_TRANSPORT = env('NEXT_PUBLIC_UPLOAD_TRANSPORT');
return await match(NEXT_PUBLIC_UPLOAD_TRANSPORT)
.with('s3', async () => putFileInObjectStorage(file, {}))
.with('azure-blob', async () => putFileInObjectStorage(file, { 'x-ms-blob-type': 'BlockBlob' }))
.with('s3', async () => putFileInObjectStorage(file, {}, options))
.with('azure-blob', async () => putFileInObjectStorage(file, { 'x-ms-blob-type': 'BlockBlob' }, options))
.otherwise(async () => putFileInDatabase(file));
};
@@ -63,11 +84,12 @@ const putFileInDatabase = async (file: File) => {
};
};
const putFileInObjectStorage = async (file: File, extraHeaders: Record<string, string>) => {
const putFileInObjectStorage = async (file: File, extraHeaders: Record<string, string>, options?: PutFileOptions) => {
const getPresignedUrlResponse = await fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/api/files/presigned-post-url`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...buildUploadAuthHeaders(options),
},
body: JSON.stringify({
fileName: file.name,
@@ -355,6 +355,14 @@ export const formatDocumentAuditLogAction = (i18n: I18n, auditLog: TDocumentAudi
you: msg`You deleted the document`,
user: msg`${user} deleted the document`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CANCELLED }, () => ({
anonymous: msg({
message: `Document cancelled`,
context: `Audit log format`,
}),
you: msg`You cancelled the document`,
user: msg`${user} cancelled the document`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELDS_AUTO_INSERTED }, () => ({
anonymous: msg({
message: `System auto inserted fields`,
+3 -1
View File
@@ -12,7 +12,9 @@ import { mapRecipientToLegacyRecipient } from './recipients';
export const isDocumentCompleted = (document: Pick<Envelope, 'status'> | DocumentStatus) => {
const status = typeof document === 'string' ? document : document.status;
return status === DocumentStatus.COMPLETED || status === DocumentStatus.REJECTED;
return (
status === DocumentStatus.COMPLETED || status === DocumentStatus.REJECTED || status === DocumentStatus.CANCELLED
);
};
/**
+3 -2
View File
@@ -242,12 +242,13 @@ export const getEnvelopeItemPermissions = (
envelope: Pick<Envelope, 'completedAt' | 'deletedAt' | 'type' | 'status'>,
recipients: Pick<Recipient, 'role' | 'signingStatus' | 'sendStatus'>[],
): EnvelopeItemPermissions => {
// Always reject completed/rejected/deleted envelopes.
// Always reject completed/rejected/cancelled/deleted envelopes.
if (
envelope.completedAt ||
envelope.deletedAt ||
envelope.status === DocumentStatus.REJECTED ||
envelope.status === DocumentStatus.COMPLETED
envelope.status === DocumentStatus.COMPLETED ||
envelope.status === DocumentStatus.CANCELLED
) {
return {
canTitleBeChanged: false,
+9 -1
View File
@@ -1,4 +1,4 @@
import type { TeamGroup, TeamMemberRole } from '@documenso/prisma/generated/types';
import { type TeamGroup, TeamMemberRole } from '@documenso/prisma/generated/types';
import type { DocumentVisibility, OrganisationGlobalSettings, Prisma, TeamGlobalSettings } from '@prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
@@ -235,3 +235,11 @@ export const extractDerivedTeamSettings = (
return derivedSettings;
};
export const isMemberManagerOrAbove = (role: TeamMemberRole) => {
return role === TeamMemberRole.ADMIN || role === TeamMemberRole.MANAGER;
};
export const isMemberAdmin = (role: TeamMemberRole) => {
return role === TeamMemberRole.ADMIN;
};