feat: add API endpoint to reject documents on behalf of recipients (#3007)

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.
This commit is contained in:
Lucas Smith
2026-06-22 21:59:07 +10:00
committed by GitHub
parent 2f24a8eab2
commit f2525ae95b
8 changed files with 623 additions and 6 deletions
@@ -0,0 +1,65 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { rejectDocumentOnBehalfOf } from '@documenso/lib/server-only/document/reject-document-on-behalf-of';
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { prisma } from '@documenso/prisma';
import { EnvelopeType } from '@prisma/client';
import { authenticatedProcedure } from '../../trpc';
import {
rejectEnvelopeRecipientOnBehalfOfMeta,
ZRejectEnvelopeRecipientOnBehalfOfRequestSchema,
ZRejectEnvelopeRecipientOnBehalfOfResponseSchema,
} from './reject-envelope-recipient-on-behalf-of.types';
export const rejectEnvelopeRecipientOnBehalfOfRoute = authenticatedProcedure
.meta(rejectEnvelopeRecipientOnBehalfOfMeta)
.input(ZRejectEnvelopeRecipientOnBehalfOfRequestSchema)
.output(ZRejectEnvelopeRecipientOnBehalfOfResponseSchema)
.mutation(async ({ input, ctx }) => {
const { teamId, user } = ctx;
const { envelopeId, recipientId, reason, actAsEmail } = input;
ctx.logger.info({
input: {
envelopeId,
recipientId,
},
});
// This is an external-only action: it must only be reachable through the
// public API, never the internal app TRPC handler.
if (ctx.metadata.source !== 'apiV2') {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'This route is only accessible via the public API',
});
}
await rejectDocumentOnBehalfOf({
envelopeId,
recipientId,
userId: user.id,
teamId,
reason,
actAsEmail,
requestMetadata: ctx.metadata,
});
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: { type: 'envelopeId', id: envelopeId },
type: EnvelopeType.DOCUMENT,
userId: user.id,
teamId,
});
const recipient = await prisma.recipient.findFirstOrThrow({
where: {
id: recipientId,
envelope: envelopeWhereInput,
},
include: {
fields: true,
},
});
return recipient;
});
@@ -0,0 +1,35 @@
import { ZEnvelopeRecipientSchema } from '@documenso/lib/types/recipient';
import { zEmail } from '@documenso/lib/utils/zod';
import { z } from 'zod';
import type { TrpcRouteMeta } from '../../trpc';
export const rejectEnvelopeRecipientOnBehalfOfMeta: TrpcRouteMeta = {
openapi: {
method: 'POST',
path: '/envelope/recipient/{recipientId}/reject',
summary: 'Reject envelope recipient on behalf of',
description:
'Records a rejection on behalf of a recipient. Use this when a recipient has declined to ' +
'sign outside of the platform. The rejection is flagged as external in the document audit ' +
'log. By default the action is attributed to the API user; supply `actAsEmail` to attribute ' +
'it to a specific team member.',
tags: ['Envelope Recipients'],
},
};
export const ZRejectEnvelopeRecipientOnBehalfOfRequestSchema = z.object({
envelopeId: z.string().describe('The ID of the envelope the recipient belongs to.'),
recipientId: z.number().describe('The ID of the recipient to reject the document on behalf of.'),
reason: z.string().min(1).describe('The reason the recipient rejected the document.'),
actAsEmail: zEmail()
.optional()
.describe('The email of the team member to attribute the rejection to. Defaults to the API user when omitted.'),
});
export const ZRejectEnvelopeRecipientOnBehalfOfResponseSchema = ZEnvelopeRecipientSchema;
export type TRejectEnvelopeRecipientOnBehalfOfRequest = z.infer<typeof ZRejectEnvelopeRecipientOnBehalfOfRequestSchema>;
export type TRejectEnvelopeRecipientOnBehalfOfResponse = z.infer<
typeof ZRejectEnvelopeRecipientOnBehalfOfResponseSchema
>;
@@ -22,6 +22,7 @@ import { updateEnvelopeFieldsRoute } from './envelope-fields/update-envelope-fie
import { createEnvelopeRecipientsRoute } from './envelope-recipients/create-envelope-recipients';
import { deleteEnvelopeRecipientRoute } from './envelope-recipients/delete-envelope-recipient';
import { getEnvelopeRecipientRoute } from './envelope-recipients/get-envelope-recipient';
import { rejectEnvelopeRecipientOnBehalfOfRoute } from './envelope-recipients/reject-envelope-recipient-on-behalf-of';
import { reportRecipientRoute } from './envelope-recipients/report-recipient';
import { updateEnvelopeRecipientsRoute } from './envelope-recipients/update-envelope-recipients';
import { findEnvelopeAuditLogsRoute } from './find-envelope-audit-logs';
@@ -70,6 +71,7 @@ export const envelopeRouter = router({
delete: deleteEnvelopeRecipientRoute,
set: setEnvelopeRecipientsRoute,
report: reportRecipientRoute,
rejectOnBehalfOf: rejectEnvelopeRecipientOnBehalfOfRoute,
},
field: {
get: getEnvelopeFieldRoute,