mirror of
https://github.com/documenso/documenso.git
synced 2026-07-11 13:35:20 +10:00
f2525ae95b
Programmatically record an external rejection on behalf of a recipient who declined outside the platform. Flags the rejection as external in the audit log, optionally attributes it to a specific team member via actAsEmail, and enforces team membership and document visibility.
216 lines
7.3 KiB
TypeScript
216 lines
7.3 KiB
TypeScript
// This is closely related to `reject-document-with-token.ts` but is intentionally
|
|
// kept as a separate method rather than merged into one. This file focuses on
|
|
// rejection from an API/programmatic perspective (an authenticated API user acting
|
|
// on behalf of a recipient), whereas `reject-document-with-token.ts` focuses on it
|
|
// from a recipient perspective (the recipient rejecting via their token).
|
|
//
|
|
// Code changes in one should probably be mirrored to the other, particularly in
|
|
// relation to the jobs triggered after a rejection.
|
|
import { jobs } from '@documenso/lib/jobs/client';
|
|
import { prisma } from '@documenso/prisma';
|
|
import { DocumentStatus, EnvelopeType, SigningStatus } from '@prisma/client';
|
|
|
|
import { AppError, AppErrorCode } from '../../errors/app-error';
|
|
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
|
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
|
|
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
|
import { mapSecondaryIdToDocumentId } from '../../utils/envelope';
|
|
import { assertRecipientNotExpired } from '../../utils/recipients';
|
|
import { buildTeamWhereQuery } from '../../utils/teams';
|
|
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
|
|
|
export type RejectDocumentOnBehalfOfOptions = {
|
|
/**
|
|
* The ID of the envelope the recipient belongs to. Required so the caller
|
|
* targets an explicit envelope/recipient combination rather than resolving the
|
|
* envelope implicitly from the recipient ID.
|
|
*/
|
|
envelopeId: string;
|
|
recipientId: number;
|
|
userId: number;
|
|
teamId: number;
|
|
reason: string;
|
|
/**
|
|
* The email of a team member to attribute the rejection to. Must be a member
|
|
* of the team. When omitted the rejection is attributed to the API user that
|
|
* owns the token (`userId`).
|
|
*
|
|
* This exists so external applications can elect which team member is acting
|
|
* on behalf of the recipient, rather than always defaulting to the API user.
|
|
*/
|
|
actAsEmail?: string;
|
|
requestMetadata: ApiRequestMetadata;
|
|
};
|
|
|
|
/**
|
|
* Reject a document on behalf of a recipient as an authenticated API user.
|
|
*
|
|
* This is used to programmatically record a rejection for cases where the
|
|
* recipient declined to sign outside of the platform (e.g. before ever
|
|
* reaching it). The rejection is flagged as `isExternal` in the audit log to
|
|
* distinguish it from a rejection performed by the recipient directly.
|
|
*
|
|
* The action can optionally be attributed to a specific team member via
|
|
* `actAsEmail`; otherwise it is attributed to the API user.
|
|
*/
|
|
export async function rejectDocumentOnBehalfOf({
|
|
envelopeId,
|
|
recipientId,
|
|
userId,
|
|
teamId,
|
|
reason,
|
|
actAsEmail,
|
|
requestMetadata,
|
|
}: RejectDocumentOnBehalfOfOptions) {
|
|
// Build the access-controlled envelope query. This enforces team membership
|
|
// AND document visibility (and owner / team-email access), mirroring the
|
|
// canonical envelope access checks used across the app.
|
|
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
|
id: { type: 'envelopeId', id: envelopeId },
|
|
type: EnvelopeType.DOCUMENT,
|
|
userId,
|
|
teamId,
|
|
});
|
|
|
|
const recipient = await prisma.recipient.findFirst({
|
|
where: {
|
|
id: recipientId,
|
|
envelope: envelopeWhereInput,
|
|
},
|
|
include: {
|
|
envelope: true,
|
|
},
|
|
});
|
|
|
|
const envelope = recipient?.envelope;
|
|
|
|
if (!recipient || !envelope) {
|
|
throw new AppError(AppErrorCode.NOT_FOUND, {
|
|
message: 'Document or recipient not found',
|
|
});
|
|
}
|
|
|
|
if (envelope.status !== DocumentStatus.PENDING) {
|
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
|
message: `Document ${envelope.id} must be pending to reject`,
|
|
});
|
|
}
|
|
|
|
if (recipient.signingStatus !== SigningStatus.NOT_SIGNED) {
|
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
|
message: `Recipient ${recipient.id} has already actioned this document`,
|
|
});
|
|
}
|
|
|
|
assertRecipientNotExpired(recipient);
|
|
|
|
// Resolve the user the rejection should be attributed to. When `actAsEmail`
|
|
// is supplied it must resolve to a member of the team; otherwise the rejection
|
|
// is attributed to the API user that owns the token.
|
|
const electedUser = await getValidatedElectedUser({ actAsEmail, teamId });
|
|
const actingUser = electedUser ?? (await prisma.user.findFirstOrThrow({ where: { id: userId } }));
|
|
|
|
// Update the recipient status to rejected and record an external rejection
|
|
// audit log within the same transaction.
|
|
const [updatedRecipient] = await prisma.$transaction([
|
|
prisma.recipient.update({
|
|
where: {
|
|
id: recipient.id,
|
|
},
|
|
data: {
|
|
signedAt: new Date(),
|
|
signingStatus: SigningStatus.REJECTED,
|
|
rejectionReason: reason,
|
|
},
|
|
}),
|
|
prisma.documentAuditLog.create({
|
|
data: createDocumentAuditLogData({
|
|
envelopeId: envelope.id,
|
|
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED,
|
|
// Always attribute the audit log to a concrete user: the elected team
|
|
// member when supplied, otherwise the API user that owns the token.
|
|
user: { id: actingUser.id, email: actingUser.email, name: actingUser.name },
|
|
metadata: requestMetadata,
|
|
data: {
|
|
recipientEmail: recipient.email,
|
|
recipientName: recipient.name,
|
|
recipientId: recipient.id,
|
|
recipientRole: recipient.role,
|
|
reason,
|
|
isExternal: true,
|
|
// Only set when a member was explicitly elected via `actAsEmail`.
|
|
onBehalfOfUserEmail: electedUser?.email,
|
|
onBehalfOfUserName: electedUser?.name,
|
|
},
|
|
}),
|
|
}),
|
|
]);
|
|
|
|
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
|
|
|
|
// Trigger the seal document job to process the document asynchronously.
|
|
await jobs.triggerJob({
|
|
name: 'internal.seal-document',
|
|
payload: {
|
|
documentId: legacyDocumentId,
|
|
requestMetadata: requestMetadata.requestMetadata,
|
|
},
|
|
});
|
|
|
|
// Send email notifications to the rejecting recipient.
|
|
await jobs.triggerJob({
|
|
name: 'send.signing.rejected.emails',
|
|
payload: {
|
|
recipientId: recipient.id,
|
|
documentId: legacyDocumentId,
|
|
},
|
|
});
|
|
|
|
// Send cancellation emails to other recipients.
|
|
await jobs.triggerJob({
|
|
name: 'send.document.cancelled.emails',
|
|
payload: {
|
|
documentId: legacyDocumentId,
|
|
cancellationReason: reason,
|
|
requestMetadata: requestMetadata.requestMetadata,
|
|
},
|
|
});
|
|
|
|
return updatedRecipient;
|
|
}
|
|
|
|
/**
|
|
* Resolve and validate the team member elected via `actAsEmail`. Returns `null`
|
|
* when no `actAsEmail` is supplied (the rejection is then attributed to the API
|
|
* user). Throws when the email does not resolve to a member of the team.
|
|
*/
|
|
const getValidatedElectedUser = async ({ actAsEmail, teamId }: { actAsEmail?: string; teamId: number }) => {
|
|
if (!actAsEmail) {
|
|
return null;
|
|
}
|
|
|
|
const electedUser = await prisma.user.findFirst({
|
|
where: {
|
|
email: actAsEmail,
|
|
},
|
|
});
|
|
|
|
if (!electedUser) {
|
|
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
|
message: 'The user to act on behalf of must be a member of the team',
|
|
});
|
|
}
|
|
|
|
const isTeamMember = await prisma.team.findFirst({
|
|
where: buildTeamWhereQuery({ teamId, userId: electedUser.id }),
|
|
});
|
|
|
|
if (!isTeamMember) {
|
|
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
|
message: 'The user to act on behalf of must be a member of the team',
|
|
});
|
|
}
|
|
|
|
return electedUser;
|
|
};
|