+
+ Document Sent Back for Correction
+
+
+
+ {isActionRequired ? (
+
+ {requestedByName} has sent the document "{documentName}" back to you for correction.
+
+ ) : (
+
+ {requestedByName} has sent the document "{documentName}" back for correction.
+
+ )}
+
+
+ {reason && (
+
+ Reason: {reason}
+
+ )}
+
+ {isActionRequired && (
+
+ Please review and update your information, then resubmit the document.
+
+ )}
+
+
+
+ );
+}
diff --git a/packages/email/templates/document-sent-back-for-correction.tsx b/packages/email/templates/document-sent-back-for-correction.tsx
new file mode 100644
index 0000000000..83860e4927
--- /dev/null
+++ b/packages/email/templates/document-sent-back-for-correction.tsx
@@ -0,0 +1,75 @@
+import { msg } from '@lingui/core/macro';
+import { useLingui } from '@lingui/react';
+
+import { Body, Container, Head, Html, Img, Preview, Section } from '../components';
+import { useBranding } from '../providers/branding';
+import { TemplateDocumentSentBackForCorrection } from '../template-components/template-document-sent-back-for-correction';
+import { TemplateFooter } from '../template-components/template-footer';
+
+export type DocumentSentBackForCorrectionEmailProps = {
+ documentName: string;
+ requestedByName: string;
+ reason: string;
+ documentUrl: string;
+ isActionRequired: boolean;
+ assetBaseUrl?: string;
+};
+
+export function DocumentSentBackForCorrectionEmail({
+ documentName,
+ requestedByName,
+ reason,
+ documentUrl,
+ isActionRequired,
+ assetBaseUrl = 'http://localhost:3002',
+}: DocumentSentBackForCorrectionEmailProps) {
+ const { _ } = useLingui();
+ const branding = useBranding();
+
+ const previewText = _(
+ msg`${requestedByName} has sent the document '${documentName}' back for correction`,
+ );
+
+ const getAssetUrl = (path: string) => {
+ return new URL(path, assetBaseUrl).toString();
+ };
+
+ return (
+
+
+ {previewText}
+
+
+
+
+
+ {branding.brandingEnabled && branding.brandingLogo ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default DocumentSentBackForCorrectionEmail;
diff --git a/packages/lib/jobs/client.ts b/packages/lib/jobs/client.ts
index 0723955f32..0bc5f5016d 100644
--- a/packages/lib/jobs/client.ts
+++ b/packages/lib/jobs/client.ts
@@ -8,6 +8,7 @@ import { SEND_OWNER_RECIPIENT_EXPIRED_EMAIL_JOB_DEFINITION } from './definitions
import { SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION } from './definitions/emails/send-password-reset-success-email';
import { SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-recipient-signed-email';
import { SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION } from './definitions/emails/send-rejection-emails';
+import { SEND_SENT_BACK_FOR_CORRECTION_EMAILS_JOB_DEFINITION } from './definitions/emails/send-sent-back-for-correction-emails';
import { SEND_SIGNING_EMAIL_JOB_DEFINITION } from './definitions/emails/send-signing-email';
import { SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-deleted-email';
import { BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION } from './definitions/internal/backport-subscription-claims';
@@ -37,6 +38,7 @@ export const jobsClient = new JobClient([
SEAL_DOCUMENT_SWEEP_JOB_DEFINITION,
SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION,
SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION,
+ SEND_SENT_BACK_FOR_CORRECTION_EMAILS_JOB_DEFINITION,
SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION,
SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION,
SEND_DOCUMENT_CREATED_FROM_DIRECT_TEMPLATE_EMAIL_JOB_DEFINITION,
diff --git a/packages/lib/jobs/definitions/emails/send-sent-back-for-correction-emails.handler.ts b/packages/lib/jobs/definitions/emails/send-sent-back-for-correction-emails.handler.ts
new file mode 100644
index 0000000000..e600cdf3d6
--- /dev/null
+++ b/packages/lib/jobs/definitions/emails/send-sent-back-for-correction-emails.handler.ts
@@ -0,0 +1,162 @@
+import { createElement } from 'react';
+
+import { msg } from '@lingui/core/macro';
+import { EnvelopeType, SendStatus } from '@prisma/client';
+
+import { mailer } from '@documenso/email/mailer';
+import { DocumentSentBackForCorrectionEmail } from '@documenso/email/templates/document-sent-back-for-correction';
+import { formatSigningLink, isRecipientEmailValidForSending } from '@documenso/lib/utils/recipients';
+import { prisma } from '@documenso/prisma';
+
+import { getI18nInstance } from '../../../client-only/providers/i18n-server';
+import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
+import { getEmailContext } from '../../../server-only/email/get-email-context';
+import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
+import { unsafeBuildEnvelopeIdQuery } from '../../../utils/envelope';
+import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
+import { formatDocumentsPath } from '../../../utils/teams';
+import type { JobRunIO } from '../../client/_internal/job';
+import type { TSendSentBackForCorrectionEmailsJobDefinition } from './send-sent-back-for-correction-emails';
+
+export const run = async ({
+ payload,
+ io,
+}: {
+ payload: TSendSentBackForCorrectionEmailsJobDefinition;
+ io: JobRunIO;
+}) => {
+ const { documentId, actorRecipientId, targetRecipientId, reason } = payload;
+
+ const envelope = await prisma.envelope.findFirstOrThrow({
+ where: unsafeBuildEnvelopeIdQuery(
+ {
+ type: 'documentId',
+ id: documentId,
+ },
+ EnvelopeType.DOCUMENT,
+ ),
+ include: {
+ user: {
+ select: {
+ id: true,
+ email: true,
+ name: true,
+ },
+ },
+ documentMeta: true,
+ team: {
+ select: {
+ teamEmail: true,
+ name: true,
+ url: true,
+ },
+ },
+ recipients: true,
+ },
+ });
+
+ const isEmailEnabled = extractDerivedDocumentEmailSettings(
+ envelope.documentMeta,
+ ).recipientSigningRequest;
+
+ if (!isEmailEnabled) {
+ return;
+ }
+
+ const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({
+ emailType: 'RECIPIENT',
+ source: {
+ type: 'team',
+ teamId: envelope.teamId,
+ },
+ meta: envelope.documentMeta,
+ });
+
+ const i18n = await getI18nInstance(emailLanguage);
+
+ const actor = actorRecipientId
+ ? envelope.recipients.find((recipient) => recipient.id === actorRecipientId)
+ : null;
+
+ const requestedByName = actor
+ ? actor.name || actor.email
+ : envelope.user.name || envelope.user.email;
+
+ const ownerDocumentUrl = `${NEXT_PUBLIC_WEBAPP_URL()}${formatDocumentsPath(envelope.team?.url)}/${envelope.id}`;
+
+ const target = targetRecipientId
+ ? envelope.recipients.find((recipient) => recipient.id === targetRecipientId)
+ : null;
+
+ const sendEmail = async (taskName: string, to: { name: string; address: string }, documentUrl: string, isActionRequired: boolean) => {
+ await io.runTask(taskName, async () => {
+ const template = createElement(DocumentSentBackForCorrectionEmail, {
+ documentName: envelope.title,
+ requestedByName,
+ reason,
+ documentUrl,
+ isActionRequired,
+ assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
+ });
+
+ const [html, text] = await Promise.all([
+ renderEmailWithI18N(template, { lang: emailLanguage, branding }),
+ renderEmailWithI18N(template, {
+ lang: emailLanguage,
+ branding,
+ plainText: true,
+ }),
+ ]);
+
+ await mailer.sendMail({
+ to,
+ from: senderEmail,
+ replyTo: replyToEmail,
+ subject: i18n._(msg`Document "${envelope.title}" - Sent Back for Correction`),
+ html,
+ text,
+ });
+ });
+ };
+
+ if (target) {
+ // Notify the recipient the document was sent back to, so they can make their correction.
+ if (isRecipientEmailValidForSending(target)) {
+ await sendEmail(
+ 'send-correction-request-email',
+ { name: target.name, address: target.email },
+ formatSigningLink(target.token),
+ true,
+ );
+
+ await io.runTask('update-target-recipient', async () => {
+ await prisma.recipient.update({
+ where: {
+ id: target.id,
+ },
+ data: {
+ sendStatus: SendStatus.SENT,
+ },
+ });
+ });
+ }
+
+ // Let the document owner know their document was routed back, unless they initiated it themselves.
+ if (actor) {
+ await sendEmail(
+ 'send-owner-notification-email',
+ { name: envelope.user.name || '', address: envelope.user.email },
+ ownerDocumentUrl,
+ false,
+ );
+ }
+ } else {
+ // Sent back to the sender: only the owner needs to act.
+ await sendEmail(
+ 'send-sender-correction-request-email',
+ { name: envelope.user.name || '', address: envelope.user.email },
+ ownerDocumentUrl,
+ true,
+ );
+ }
+};
diff --git a/packages/lib/jobs/definitions/emails/send-sent-back-for-correction-emails.ts b/packages/lib/jobs/definitions/emails/send-sent-back-for-correction-emails.ts
new file mode 100644
index 0000000000..d6f46704f8
--- /dev/null
+++ b/packages/lib/jobs/definitions/emails/send-sent-back-for-correction-emails.ts
@@ -0,0 +1,35 @@
+import { z } from 'zod';
+
+import { type JobDefinition } from '../../client/_internal/job';
+
+const SEND_SENT_BACK_FOR_CORRECTION_EMAILS_JOB_DEFINITION_ID =
+ 'send.document.sent-back-for-correction.emails';
+
+const SEND_SENT_BACK_FOR_CORRECTION_EMAILS_JOB_DEFINITION_SCHEMA = z.object({
+ documentId: z.number(),
+ actorRecipientId: z.number().nullable(),
+ targetRecipientId: z.number().nullable(),
+ reason: z.string(),
+});
+
+export type TSendSentBackForCorrectionEmailsJobDefinition = z.infer<
+ typeof SEND_SENT_BACK_FOR_CORRECTION_EMAILS_JOB_DEFINITION_SCHEMA
+>;
+
+export const SEND_SENT_BACK_FOR_CORRECTION_EMAILS_JOB_DEFINITION = {
+ id: SEND_SENT_BACK_FOR_CORRECTION_EMAILS_JOB_DEFINITION_ID,
+ name: 'Send Sent Back For Correction Emails',
+ version: '1.0.0',
+ trigger: {
+ name: SEND_SENT_BACK_FOR_CORRECTION_EMAILS_JOB_DEFINITION_ID,
+ schema: SEND_SENT_BACK_FOR_CORRECTION_EMAILS_JOB_DEFINITION_SCHEMA,
+ },
+ handler: async ({ payload, io }) => {
+ const handler = await import('./send-sent-back-for-correction-emails.handler');
+
+ await handler.run({ payload, io });
+ },
+} as const satisfies JobDefinition<
+ typeof SEND_SENT_BACK_FOR_CORRECTION_EMAILS_JOB_DEFINITION_ID,
+ TSendSentBackForCorrectionEmailsJobDefinition
+>;
diff --git a/packages/lib/server-only/document/send-document-back-for-correction.ts b/packages/lib/server-only/document/send-document-back-for-correction.ts
new file mode 100644
index 0000000000..fa10ccd797
--- /dev/null
+++ b/packages/lib/server-only/document/send-document-back-for-correction.ts
@@ -0,0 +1,283 @@
+import type { Prisma } from '@prisma/client';
+import { DocumentStatus, EnvelopeType, RecipientRole, SigningStatus } from '@prisma/client';
+
+import { jobs } from '@documenso/lib/jobs/client';
+import { prisma } from '@documenso/prisma';
+
+import { AppError, AppErrorCode } from '../../errors/app-error';
+import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
+import type { ApiRequestMetadata, RequestMetadata } 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 { assertRecipientNotExpired } from '../../utils/recipients';
+import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
+
+/**
+ * Roles that fill out fields on a document, and can therefore be sent back to for correction.
+ */
+const CORRECTABLE_RECIPIENT_ROLES: RecipientRole[] = [
+ RecipientRole.SIGNER,
+ RecipientRole.APPROVER,
+ RecipientRole.ASSISTANT,
+];
+
+const findCorrectableTarget = (
+ recipients: {
+ id: number;
+ name: string;
+ email: string;
+ role: RecipientRole;
+ signingStatus: SigningStatus;
+ }[],
+ targetRecipientId: number,
+ actorRecipientId: number | null,
+) => {
+ const target = recipients.find((recipient) => recipient.id === targetRecipientId);
+
+ if (!target) {
+ throw new AppError(AppErrorCode.NOT_FOUND, {
+ message: 'Target recipient not found on this document',
+ });
+ }
+
+ if (target.id === actorRecipientId) {
+ throw new AppError(AppErrorCode.INVALID_REQUEST, {
+ message: 'Cannot send the document back to yourself',
+ });
+ }
+
+ if (!CORRECTABLE_RECIPIENT_ROLES.includes(target.role)) {
+ throw new AppError(AppErrorCode.INVALID_REQUEST, {
+ message: 'Target recipient cannot be sent a correction, they do not fill out fields',
+ });
+ }
+
+ if (target.signingStatus !== SigningStatus.SIGNED) {
+ throw new AppError(AppErrorCode.INVALID_REQUEST, {
+ message: 'Can only send the document back to a recipient who has already completed it',
+ });
+ }
+
+ return target;
+};
+
+/**
+ * Resets a recipient back to NOT_SIGNED and clears the fields/signatures they had inserted,
+ * so they can redo their part of the document. Every other recipient's data is left untouched.
+ */
+const resetRecipientForCorrection = async (
+ tx: Prisma.TransactionClient,
+ targetRecipientId: number,
+) => {
+ await tx.recipient.update({
+ where: {
+ id: targetRecipientId,
+ },
+ data: {
+ signingStatus: SigningStatus.NOT_SIGNED,
+ signedAt: null,
+ },
+ });
+
+ await tx.field.updateMany({
+ where: {
+ recipientId: targetRecipientId,
+ },
+ data: {
+ customText: '',
+ inserted: false,
+ },
+ });
+
+ await tx.signature.deleteMany({
+ where: {
+ recipientId: targetRecipientId,
+ },
+ });
+};
+
+export type SendDocumentBackForCorrectionWithTokenOptions = {
+ token: string;
+ id: EnvelopeIdOptions;
+ /**
+ * The recipient to send the document back to, or null to send it back to the sender.
+ */
+ targetRecipientId: number | null;
+ reason: string;
+ requestMetadata?: RequestMetadata;
+};
+
+export const sendDocumentBackForCorrectionWithToken = async ({
+ token,
+ id,
+ targetRecipientId,
+ reason,
+ requestMetadata,
+}: SendDocumentBackForCorrectionWithTokenOptions) => {
+ const actor = await prisma.recipient.findFirst({
+ where: {
+ token,
+ envelope: unsafeBuildEnvelopeIdQuery(id, EnvelopeType.DOCUMENT),
+ },
+ include: {
+ envelope: {
+ include: {
+ recipients: true,
+ },
+ },
+ },
+ });
+
+ const envelope = actor?.envelope;
+
+ if (!actor || !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 send back for correction`,
+ });
+ }
+
+ assertRecipientNotExpired(actor);
+
+ const target =
+ targetRecipientId === null
+ ? null
+ : findCorrectableTarget(envelope.recipients, targetRecipientId, actor.id);
+
+ await prisma.$transaction(async (tx) => {
+ if (target) {
+ await resetRecipientForCorrection(tx, target.id);
+ }
+
+ await tx.documentAuditLog.create({
+ data: createDocumentAuditLogData({
+ envelopeId: envelope.id,
+ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_SENT_BACK_FOR_CORRECTION,
+ user: {
+ name: actor.name,
+ email: actor.email,
+ },
+ data: {
+ reason,
+ targetRecipientId: target?.id ?? null,
+ targetRecipientName: target?.name ?? null,
+ targetRecipientEmail: target?.email ?? null,
+ },
+ requestMetadata,
+ }),
+ });
+ });
+
+ const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
+
+ await jobs.triggerJob({
+ name: 'send.document.sent-back-for-correction.emails',
+ payload: {
+ documentId: legacyDocumentId,
+ actorRecipientId: actor.id,
+ targetRecipientId: target?.id ?? null,
+ reason,
+ },
+ });
+
+ return { targetRecipientId: target?.id ?? null };
+};
+
+export type SendDocumentBackForCorrectionOptions = {
+ id: EnvelopeIdOptions;
+ userId: number;
+ teamId: number;
+ targetRecipientId: number;
+ reason: string;
+ requestMetadata?: ApiRequestMetadata;
+};
+
+export const sendDocumentBackForCorrection = async ({
+ id,
+ userId,
+ teamId,
+ targetRecipientId,
+ reason,
+ requestMetadata,
+}: SendDocumentBackForCorrectionOptions) => {
+ const user = await prisma.user.findFirstOrThrow({
+ where: {
+ id: userId,
+ },
+ select: {
+ id: true,
+ email: true,
+ name: true,
+ },
+ });
+
+ const { envelopeWhereInput } = await getEnvelopeWhereInput({
+ id,
+ type: EnvelopeType.DOCUMENT,
+ userId,
+ teamId,
+ });
+
+ const envelope = await prisma.envelope.findUnique({
+ where: envelopeWhereInput,
+ include: {
+ recipients: true,
+ },
+ });
+
+ if (!envelope) {
+ throw new AppError(AppErrorCode.NOT_FOUND, {
+ message: 'Document not found',
+ });
+ }
+
+ if (envelope.status !== DocumentStatus.PENDING) {
+ throw new AppError(AppErrorCode.INVALID_REQUEST, {
+ message: `Document ${envelope.id} must be pending to send back for correction`,
+ });
+ }
+
+ const target = findCorrectableTarget(envelope.recipients, targetRecipientId, null);
+
+ await prisma.$transaction(async (tx) => {
+ await resetRecipientForCorrection(tx, target.id);
+
+ await tx.documentAuditLog.create({
+ data: createDocumentAuditLogData({
+ envelopeId: envelope.id,
+ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_SENT_BACK_FOR_CORRECTION,
+ user: {
+ name: user.name,
+ email: user.email,
+ },
+ data: {
+ reason,
+ targetRecipientId: target.id,
+ targetRecipientName: target.name,
+ targetRecipientEmail: target.email,
+ },
+ metadata: requestMetadata,
+ }),
+ });
+ });
+
+ const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
+
+ await jobs.triggerJob({
+ name: 'send.document.sent-back-for-correction.emails',
+ payload: {
+ documentId: legacyDocumentId,
+ actorRecipientId: null,
+ targetRecipientId: target.id,
+ reason,
+ },
+ });
+
+ return { targetRecipientId: target.id };
+};
diff --git a/packages/lib/types/document-audit-logs.ts b/packages/lib/types/document-audit-logs.ts
index e02cde1bbf..45e77c4d26 100644
--- a/packages/lib/types/document-audit-logs.ts
+++ b/packages/lib/types/document-audit-logs.ts
@@ -43,6 +43,7 @@ export const ZDocumentAuditLogTypeSchema = z.enum([
'DOCUMENT_OPENED', // When the document is opened by a recipient.
'DOCUMENT_VIEWED', // When the document is viewed by a recipient.
'DOCUMENT_RECIPIENT_REJECTED', // When a recipient rejects the document.
+ 'DOCUMENT_RECIPIENT_SENT_BACK_FOR_CORRECTION', // When a recipient or owner sends the document back to an earlier recipient (or the sender) for correction.
'DOCUMENT_RECIPIENT_COMPLETED', // When a recipient completes all their required tasks for the document.
'DOCUMENT_RECIPIENT_EXPIRED', // When a recipient's signing window expires.
'DOCUMENT_SENT', // When the document transitions from DRAFT to PENDING.
@@ -598,6 +599,22 @@ export const ZDocumentAuditLogEventDocumentRecipientRejectedSchema = z.object({
}),
});
+/**
+ * Event: Document sent back to an earlier recipient (or the sender) for correction.
+ *
+ * The actor (who initiated the send back) is captured by the base audit log fields
+ * (name/email/userId), since the actor may be a recipient or the document owner.
+ */
+export const ZDocumentAuditLogEventDocumentRecipientSentBackForCorrectionSchema = z.object({
+ type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_SENT_BACK_FOR_CORRECTION),
+ data: z.object({
+ reason: z.string(),
+ targetRecipientId: z.number().nullable(),
+ targetRecipientName: z.string().nullable(),
+ targetRecipientEmail: z.string().nullable(),
+ }),
+});
+
/**
* Event: Document recipient requested a 2FA token.
*/
@@ -805,6 +822,7 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
ZDocumentAuditLogEventDocumentViewedSchema,
ZDocumentAuditLogEventDocumentRecipientCompleteSchema,
ZDocumentAuditLogEventDocumentRecipientRejectedSchema,
+ ZDocumentAuditLogEventDocumentRecipientSentBackForCorrectionSchema,
ZDocumentAuditLogEventDocumentRecipientRequested2FAEmailSchema,
ZDocumentAuditLogEventDocumentRecipientValidated2FAEmailSchema,
ZDocumentAuditLogEventDocumentRecipientFailed2FAEmailSchema,
diff --git a/packages/lib/utils/document-audit-logs.ts b/packages/lib/utils/document-audit-logs.ts
index 5a4ed9a402..e63b814664 100644
--- a/packages/lib/utils/document-audit-logs.ts
+++ b/packages/lib/utils/document-audit-logs.ts
@@ -515,6 +515,26 @@ export const formatDocumentAuditLogAction = (
you: msg`You rejected the document`,
user: msg`${user} rejected the document`,
}))
+ .with(
+ { type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_SENT_BACK_FOR_CORRECTION },
+ ({ data }) => {
+ const target = data.targetRecipientName || data.targetRecipientEmail;
+
+ if (!target) {
+ return {
+ anonymous: msg`Document sent back to the sender for correction`,
+ you: msg`You sent the document back to the sender for correction`,
+ user: msg`${user} sent the document back to the sender for correction`,
+ };
+ }
+
+ return {
+ anonymous: msg`Document sent back to ${target} for correction`,
+ you: msg`You sent the document back to ${target} for correction`,
+ user: msg`${user} sent the document back to ${target} for correction`,
+ };
+ },
+ )
.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/router.ts b/packages/trpc/server/envelope-router/router.ts
index 65b03014ae..be86e154b2 100644
--- a/packages/trpc/server/envelope-router/router.ts
+++ b/packages/trpc/server/envelope-router/router.ts
@@ -33,6 +33,7 @@ import { getEnvelopesByIdsRoute } from './get-envelopes-by-ids';
import { redistributeEnvelopeRoute } from './redistribute-envelope';
import { replaceEnvelopeItemPdfRoute } from './replace-envelope-item-pdf';
import { saveAsTemplateRoute } from './save-as-template';
+import { sendBackForCorrectionRoute } from './send-back-for-correction';
import { setEnvelopeFieldsRoute } from './set-envelope-fields';
import { setEnvelopeRecipientsRoute } from './set-envelope-recipients';
import { signEnvelopeFieldRoute } from './sign-envelope-field';
@@ -101,5 +102,6 @@ export const envelopeRouter = router({
distribute: distributeEnvelopeRoute,
cancelScheduledSend: cancelScheduledSendRoute,
redistribute: redistributeEnvelopeRoute,
+ sendBackForCorrection: sendBackForCorrectionRoute,
signingStatus: signingStatusEnvelopeRoute,
});
diff --git a/packages/trpc/server/envelope-router/send-back-for-correction.ts b/packages/trpc/server/envelope-router/send-back-for-correction.ts
new file mode 100644
index 0000000000..9672bb1356
--- /dev/null
+++ b/packages/trpc/server/envelope-router/send-back-for-correction.ts
@@ -0,0 +1,42 @@
+import { sendDocumentBackForCorrection } from '@documenso/lib/server-only/document/send-document-back-for-correction';
+
+import { authenticatedProcedure } from '../trpc';
+import {
+ ZSendBackForCorrectionRequestSchema,
+ ZSendBackForCorrectionResponseSchema,
+ sendBackForCorrectionMeta,
+} from './send-back-for-correction.types';
+
+export const sendBackForCorrectionRoute = authenticatedProcedure
+ .meta(sendBackForCorrectionMeta)
+ .input(ZSendBackForCorrectionRequestSchema)
+ .output(ZSendBackForCorrectionResponseSchema)
+ .mutation(async ({ input, ctx }) => {
+ const { teamId } = ctx;
+ const { envelopeId, targetRecipientId, reason } = input;
+
+ ctx.logger.info({
+ input: {
+ envelopeId,
+ targetRecipientId,
+ },
+ });
+
+ const { targetRecipientId: sentBackTo } = await sendDocumentBackForCorrection({
+ userId: ctx.user.id,
+ teamId,
+ id: {
+ type: 'envelopeId',
+ id: envelopeId,
+ },
+ targetRecipientId,
+ reason,
+ requestMetadata: ctx.metadata,
+ });
+
+ return {
+ success: true,
+ id: envelopeId,
+ targetRecipientId: sentBackTo,
+ };
+ });
diff --git a/packages/trpc/server/envelope-router/send-back-for-correction.types.ts b/packages/trpc/server/envelope-router/send-back-for-correction.types.ts
new file mode 100644
index 0000000000..b069603c2f
--- /dev/null
+++ b/packages/trpc/server/envelope-router/send-back-for-correction.types.ts
@@ -0,0 +1,31 @@
+import { z } from 'zod';
+
+import { ZSuccessResponseSchema } from '../schema';
+import type { TrpcRouteMeta } from '../trpc';
+
+export const sendBackForCorrectionMeta: TrpcRouteMeta = {
+ openapi: {
+ method: 'POST',
+ path: '/envelope/send-back-for-correction',
+ summary: 'Send envelope back for correction',
+ description:
+ 'Send a pending envelope back to a recipient who has already completed their part, so they can correct their submission. The recipient is reset to pending and their fields are cleared; other recipients are unaffected.',
+ tags: ['Envelope'],
+ },
+};
+
+export const ZSendBackForCorrectionRequestSchema = z.object({
+ envelopeId: z.string(),
+ targetRecipientId: z
+ .number()
+ .describe('The ID of the recipient to send the envelope back to for correction.'),
+ reason: z.string().min(1).max(500),
+});
+
+export const ZSendBackForCorrectionResponseSchema = ZSuccessResponseSchema.extend({
+ id: z.string().describe('The ID of the envelope that was sent back for correction.'),
+ targetRecipientId: z.number(),
+});
+
+export type TSendBackForCorrectionRequest = z.infer;
+export type TSendBackForCorrectionResponse = z.infer;
diff --git a/packages/trpc/server/recipient-router/router.ts b/packages/trpc/server/recipient-router/router.ts
index 0548c12705..cf0eb24709 100644
--- a/packages/trpc/server/recipient-router/router.ts
+++ b/packages/trpc/server/recipient-router/router.ts
@@ -2,6 +2,7 @@ import { EnvelopeType } from '@prisma/client';
import { completeDocumentWithToken } from '@documenso/lib/server-only/document/complete-document-with-token';
import { rejectDocumentWithToken } from '@documenso/lib/server-only/document/reject-document-with-token';
+import { sendDocumentBackForCorrectionWithToken } from '@documenso/lib/server-only/document/send-document-back-for-correction';
import { createEnvelopeRecipients } from '@documenso/lib/server-only/recipient/create-envelope-recipients';
import { deleteEnvelopeRecipient } from '@documenso/lib/server-only/recipient/delete-envelope-recipient';
import { getRecipientById } from '@documenso/lib/server-only/recipient/get-recipient-by-id';
@@ -27,6 +28,7 @@ import {
ZGetRecipientRequestSchema,
ZGetRecipientResponseSchema,
ZRejectDocumentWithTokenMutationSchema,
+ ZSendDocumentBackForCorrectionWithTokenMutationSchema,
ZSetDocumentRecipientsRequestSchema,
ZSetDocumentRecipientsResponseSchema,
ZSetTemplateRecipientsRequestSchema,
@@ -607,4 +609,30 @@ export const recipientRouter = router({
requestMetadata: ctx.metadata.requestMetadata,
});
}),
+
+ /**
+ * @private
+ */
+ sendDocumentBackForCorrectionWithToken: procedure
+ .input(ZSendDocumentBackForCorrectionWithTokenMutationSchema)
+ .mutation(async ({ input, ctx }) => {
+ const { token, documentId, targetRecipientId, reason } = input;
+
+ ctx.logger.info({
+ input: {
+ documentId,
+ },
+ });
+
+ return await sendDocumentBackForCorrectionWithToken({
+ token,
+ id: {
+ type: 'documentId',
+ id: documentId,
+ },
+ targetRecipientId,
+ reason,
+ requestMetadata: ctx.metadata.requestMetadata,
+ });
+ }),
});
diff --git a/packages/trpc/server/recipient-router/schema.ts b/packages/trpc/server/recipient-router/schema.ts
index 073104bba1..7685a55f72 100644
--- a/packages/trpc/server/recipient-router/schema.ts
+++ b/packages/trpc/server/recipient-router/schema.ts
@@ -191,3 +191,17 @@ export const ZRejectDocumentWithTokenMutationSchema = z.object({
export type TRejectDocumentWithTokenMutationSchema = z.infer<
typeof ZRejectDocumentWithTokenMutationSchema
>;
+
+export const ZSendDocumentBackForCorrectionWithTokenMutationSchema = z.object({
+ token: z.string(),
+ documentId: z.number(),
+ /**
+ * The recipient to send the document back to, or null to send it back to the sender.
+ */
+ targetRecipientId: z.number().nullable(),
+ reason: z.string().min(1).max(500),
+});
+
+export type TSendDocumentBackForCorrectionWithTokenMutationSchema = z.infer<
+ typeof ZSendDocumentBackForCorrectionWithTokenMutationSchema
+>;