mirror of
https://github.com/documenso/documenso.git
synced 2026-06-22 04:12:06 +10:00
4f346d3c2d
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.
130 lines
4.0 KiB
TypeScript
130 lines
4.0 KiB
TypeScript
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;
|
|
};
|