From e88735694906524f0b23cd5edd9fcb41dd683fbc Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Mon, 22 Jun 2026 14:27:18 +1000 Subject: [PATCH] feat: add API endpoint to reject documents on behalf of recipients 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. --- .../v2/reject-recipient-on-behalf-of.spec.ts | 260 ++++++++++++++++++ .../document/reject-document-on-behalf-of.ts | 207 ++++++++++++++ packages/lib/types/document-audit-logs.ts | 14 +- packages/lib/utils/document-audit-logs.ts | 30 +- .../reject-envelope-recipient-on-behalf-of.ts | 65 +++++ ...t-envelope-recipient-on-behalf-of.types.ts | 35 +++ .../trpc/server/envelope-router/router.ts | 2 + 7 files changed, 607 insertions(+), 6 deletions(-) create mode 100644 packages/app-tests/e2e/api/v2/reject-recipient-on-behalf-of.spec.ts create mode 100644 packages/lib/server-only/document/reject-document-on-behalf-of.ts create mode 100644 packages/trpc/server/envelope-router/envelope-recipients/reject-envelope-recipient-on-behalf-of.ts create mode 100644 packages/trpc/server/envelope-router/envelope-recipients/reject-envelope-recipient-on-behalf-of.types.ts diff --git a/packages/app-tests/e2e/api/v2/reject-recipient-on-behalf-of.spec.ts b/packages/app-tests/e2e/api/v2/reject-recipient-on-behalf-of.spec.ts new file mode 100644 index 000000000..afe5a3cd9 --- /dev/null +++ b/packages/app-tests/e2e/api/v2/reject-recipient-on-behalf-of.spec.ts @@ -0,0 +1,260 @@ +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token'; +import { prisma } from '@documenso/prisma'; +import { DocumentVisibility, SigningStatus, TeamMemberRole } from '@documenso/prisma/client'; +import { seedPendingDocument } from '@documenso/prisma/seed/documents'; +import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams'; +import { seedUser } from '@documenso/prisma/seed/users'; +import type { TRejectEnvelopeRecipientOnBehalfOfRequest } from '@documenso/trpc/server/envelope-router/envelope-recipients/reject-envelope-recipient-on-behalf-of.types'; +import { type APIRequestContext, expect, test } from '@playwright/test'; +import type { Team, User } from '@prisma/client'; + +const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL(); +const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`; + +test.describe.configure({ + mode: 'parallel', +}); + +const rejectRecipient = ( + request: APIRequestContext, + authToken: string, + envelopeId: string, + recipientId: number, + reason: string, + actAsEmail?: string, +) => { + return request.post(`${baseUrl}/envelope/recipient/${recipientId}/reject`, { + headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' }, + data: { + envelopeId, + recipientId, + reason, + actAsEmail, + } satisfies TRejectEnvelopeRecipientOnBehalfOfRequest, + }); +}; + +test.describe('Reject recipient on behalf of', () => { + let user: User; + let team: Team; + let token: string; + + test.beforeEach(async () => { + ({ user, team } = await seedUser()); + ({ token } = await createApiToken({ + userId: user.id, + teamId: team.id, + tokenName: 'test-reject-recipient', + expiresIn: null, + })); + }); + + test('should reject a recipient and record an external rejection audit log', async ({ request }) => { + const envelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']); + const recipient = envelope.recipients[0]; + + const res = await rejectRecipient(request, token, envelope.id, recipient.id, 'Declined out of band'); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + + const updatedRecipient = await prisma.recipient.findUniqueOrThrow({ + where: { id: recipient.id }, + }); + + expect(updatedRecipient.signingStatus).toBe(SigningStatus.REJECTED); + expect(updatedRecipient.rejectionReason).toBe('Declined out of band'); + + const auditLog = await prisma.documentAuditLog.findFirst({ + where: { + envelopeId: envelope.id, + type: 'DOCUMENT_RECIPIENT_REJECTED', + }, + orderBy: { createdAt: 'desc' }, + }); + + expect(auditLog).not.toBeNull(); + + const auditData = auditLog!.data as Record; + + expect(auditData.recipientId).toBe(recipient.id); + expect(auditData.recipientEmail).toBe(recipient.email); + expect(auditData.reason).toBe('Declined out of band'); + expect(auditData.isExternal).toBe(true); + + // No actAsEmail supplied - the rejection defaults to the API user. + expect(auditLog!.userId).toBe(user.id); + expect(auditLog!.email).toBe(user.email); + expect(auditData.onBehalfOfUserEmail).toBeUndefined(); + }); + + test('should attribute the rejection to the elected team member when actAsEmail is supplied', async ({ request }) => { + const member = await seedTeamMember({ teamId: team.id }); + + const envelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']); + const recipient = envelope.recipients[0]; + + const res = await rejectRecipient(request, token, envelope.id, recipient.id, 'Declined out of band', member.email); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + + const auditLog = await prisma.documentAuditLog.findFirstOrThrow({ + where: { + envelopeId: envelope.id, + type: 'DOCUMENT_RECIPIENT_REJECTED', + }, + orderBy: { createdAt: 'desc' }, + }); + + // The audit log actor must be the elected member, not the API user. + expect(auditLog.userId).toBe(member.id); + expect(auditLog.email).toBe(member.email); + + const auditData = auditLog.data as Record; + + expect(auditData.isExternal).toBe(true); + expect(auditData.onBehalfOfUserEmail).toBe(member.email); + }); + + test('should reject when actAsEmail is not a member of the team', async ({ request }) => { + // A user that exists but belongs to a different team. + const { user: outsider } = await seedUser(); + + const envelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']); + const recipient = envelope.recipients[0]; + + const res = await rejectRecipient( + request, + token, + envelope.id, + recipient.id, + 'Declined out of band', + outsider.email, + ); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(401); + + // The recipient must remain untouched. + const untouchedRecipient = await prisma.recipient.findUniqueOrThrow({ + where: { id: recipient.id }, + }); + + expect(untouchedRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED); + expect(untouchedRecipient.rejectionReason).toBeNull(); + }); + + test('should deny rejecting a recipient that has already actioned the document', async ({ request }) => { + const envelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']); + const recipient = envelope.recipients[0]; + + // Reject once - succeeds. + const firstRes = await rejectRecipient(request, token, envelope.id, recipient.id, 'First rejection'); + expect(firstRes.ok()).toBeTruthy(); + + // Reject again - the recipient is no longer NOT_SIGNED. + const secondRes = await rejectRecipient(request, token, envelope.id, recipient.id, 'Second rejection'); + + expect(secondRes.ok()).toBeFalsy(); + expect(secondRes.status()).toBe(400); + + // The original rejection reason must remain unchanged. + const updatedRecipient = await prisma.recipient.findUniqueOrThrow({ + where: { id: recipient.id }, + }); + + expect(updatedRecipient.rejectionReason).toBe('First rejection'); + }); + + test('should not allow rejecting a recipient in another team', async ({ request }) => { + // Seed a separate team/user that owns the document. + const { user: otherUser, team: otherTeam } = await seedUser(); + + const envelope = await seedPendingDocument(otherUser, otherTeam.id, ['recipient@test.documenso.com']); + const recipient = envelope.recipients[0]; + + // Use the original team's token - it must not be able to reject. + const res = await rejectRecipient(request, token, envelope.id, recipient.id, 'Should not work'); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(404); + + // The recipient must remain untouched. + const untouchedRecipient = await prisma.recipient.findUniqueOrThrow({ + where: { id: recipient.id }, + }); + + expect(untouchedRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED); + expect(untouchedRecipient.rejectionReason).toBeNull(); + }); + + test('should return 404 for a non-existent recipient', async ({ request }) => { + const envelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']); + + const res = await rejectRecipient(request, token, envelope.id, 999999999, 'No such recipient'); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(404); + }); + + test('should return 404 when the recipient does not belong to the supplied envelope', async ({ request }) => { + const targetEnvelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']); + const otherEnvelope = await seedPendingDocument(user, team.id, ['other-recipient@test.documenso.com']); + + const recipient = targetEnvelope.recipients[0]; + + // Valid recipient ID, but paired with the wrong envelope ID. + const res = await rejectRecipient(request, token, otherEnvelope.id, recipient.id, 'Mismatched envelope'); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(404); + + // The recipient must remain untouched. + const untouchedRecipient = await prisma.recipient.findUniqueOrThrow({ + where: { id: recipient.id }, + }); + + expect(untouchedRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED); + expect(untouchedRecipient.rejectionReason).toBeNull(); + }); + + test('should enforce document visibility: manager cannot reject on an ADMIN-only document', async ({ request }) => { + // The API token belongs to a MANAGER, who cannot see ADMIN-visibility docs. + const { team: visTeam, owner } = await seedTeam(); + const manager = await seedTeamMember({ teamId: visTeam.id, role: TeamMemberRole.MANAGER }); + + const { token: managerToken } = await createApiToken({ + userId: manager.id, + teamId: visTeam.id, + tokenName: 'manager-reject-token', + expiresIn: null, + }); + + // ADMIN-visibility document owned by the team owner. + const envelope = await seedPendingDocument(owner, visTeam.id, ['recipient@test.documenso.com'], { + createDocumentOptions: { visibility: DocumentVisibility.ADMIN }, + }); + const recipient = envelope.recipients[0]; + + const res = await rejectRecipient( + request, + managerToken, + envelope.id, + recipient.id, + 'Should be hidden by visibility', + ); + + // Visibility failure surfaces as not-found, matching the canonical checks. + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(404); + + const untouchedRecipient = await prisma.recipient.findUniqueOrThrow({ + where: { id: recipient.id }, + }); + + expect(untouchedRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED); + expect(untouchedRecipient.rejectionReason).toBeNull(); + }); +}); diff --git a/packages/lib/server-only/document/reject-document-on-behalf-of.ts b/packages/lib/server-only/document/reject-document-on-behalf-of.ts new file mode 100644 index 000000000..5c921cc8e --- /dev/null +++ b/packages/lib/server-only/document/reject-document-on-behalf-of.ts @@ -0,0 +1,207 @@ +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; +}; diff --git a/packages/lib/types/document-audit-logs.ts b/packages/lib/types/document-audit-logs.ts index b1ca3f040..b81ff0530 100644 --- a/packages/lib/types/document-audit-logs.ts +++ b/packages/lib/types/document-audit-logs.ts @@ -560,12 +560,24 @@ export const ZDocumentAuditLogEventDocumentRecipientCompleteSchema = z.object({ }); /** - * Event: Document recipient completed the document (the recipient has fully actioned and completed their required steps for the document). + * Event: Document recipient rejected the document. */ export const ZDocumentAuditLogEventDocumentRecipientRejectedSchema = z.object({ type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED), data: ZBaseRecipientDataSchema.extend({ reason: z.string(), + /** + * Whether the rejection was recorded externally on behalf of the recipient + * via the API, rather than by the recipient directly on the platform. + */ + isExternal: z.boolean().optional(), + /** + * The team member the external rejection was recorded on behalf of, when + * the API caller elected a specific member to attribute the action to. + * Absent when the rejection is attributed to the API user/token itself. + */ + onBehalfOfUserEmail: z.string().optional(), + onBehalfOfUserName: z.string().nullable().optional(), }), }); diff --git a/packages/lib/utils/document-audit-logs.ts b/packages/lib/utils/document-audit-logs.ts index 66659f081..400b8dc53 100644 --- a/packages/lib/utils/document-audit-logs.ts +++ b/packages/lib/utils/document-audit-logs.ts @@ -509,11 +509,31 @@ export const formatDocumentAuditLogAction = (i18n: I18n, auditLog: TDocumentAudi user: msg`${user} completed their task`, })); }) - .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED }, () => ({ - anonymous: msg`Recipient rejected the document`, - you: msg`You rejected the document`, - user: msg`${user} rejected the document`, - })) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED }, ({ data }) => { + if (data.isExternal) { + const onBehalfOf = data.onBehalfOfUserName || data.onBehalfOfUserEmail; + + if (onBehalfOf) { + return { + anonymous: msg`The document was rejected externally by ${onBehalfOf} on behalf of the recipient`, + you: msg`The document was rejected externally by ${onBehalfOf} on behalf of the recipient`, + user: msg`The document was rejected externally by ${onBehalfOf} on behalf of ${user}`, + }; + } + + return { + anonymous: msg`Recipient rejected the document externally`, + you: msg`The document was rejected externally on behalf of the recipient`, + user: msg`The document was rejected externally on behalf of ${user}`, + }; + } + + return { + anonymous: msg`Recipient rejected the document`, + you: msg`You rejected the document`, + user: msg`${user} rejected the document`, + }; + }) .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_REQUESTED }, () => ({ anonymous: msg`Recipient requested a 2FA token for the document`, you: msg`You requested a 2FA token for the document`, diff --git a/packages/trpc/server/envelope-router/envelope-recipients/reject-envelope-recipient-on-behalf-of.ts b/packages/trpc/server/envelope-router/envelope-recipients/reject-envelope-recipient-on-behalf-of.ts new file mode 100644 index 000000000..f56971bdb --- /dev/null +++ b/packages/trpc/server/envelope-router/envelope-recipients/reject-envelope-recipient-on-behalf-of.ts @@ -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; + }); diff --git a/packages/trpc/server/envelope-router/envelope-recipients/reject-envelope-recipient-on-behalf-of.types.ts b/packages/trpc/server/envelope-router/envelope-recipients/reject-envelope-recipient-on-behalf-of.types.ts new file mode 100644 index 000000000..7f96aaefe --- /dev/null +++ b/packages/trpc/server/envelope-router/envelope-recipients/reject-envelope-recipient-on-behalf-of.types.ts @@ -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; +export type TRejectEnvelopeRecipientOnBehalfOfResponse = z.infer< + typeof ZRejectEnvelopeRecipientOnBehalfOfResponseSchema +>; diff --git a/packages/trpc/server/envelope-router/router.ts b/packages/trpc/server/envelope-router/router.ts index 8aa725014..1dd54e436 100644 --- a/packages/trpc/server/envelope-router/router.ts +++ b/packages/trpc/server/envelope-router/router.ts @@ -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,