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
@@ -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',