From fc95ee9ead6e93a4b3af95067472c9f18b61a07b Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Mon, 10 Aug 2026 09:25:41 +1000 Subject: [PATCH] fix: handle completion when already signed (#3159) Previously attempting to complete a document which is already completed you'd get a generic error toast. Now when completing a document that you have already completed you are redirected to the completed page. Handles cases where two mutations managed to fire racing eachother. --- .../envelope-signing-complete-dialog.tsx | 28 +++++++++---- packages/lib/errors/app-error.ts | 7 ++++ .../document/complete-document-with-token.ts | 39 +++++++++++++++---- .../trpc/server/recipient-router/router.ts | 13 ++++++- .../trpc/server/recipient-router/schema.ts | 8 +++- 5 files changed, 77 insertions(+), 18 deletions(-) diff --git a/apps/remix/app/components/general/envelope-signing/envelope-signing-complete-dialog.tsx b/apps/remix/app/components/general/envelope-signing/envelope-signing-complete-dialog.tsx index 2035e65b8..53f125c7f 100644 --- a/apps/remix/app/components/general/envelope-signing/envelope-signing-complete-dialog.tsx +++ b/apps/remix/app/components/general/envelope-signing/envelope-signing-complete-dialog.tsx @@ -42,7 +42,11 @@ export const EnvelopeSignerCompleteDialog = () => { const { onDocumentCompleted, onDocumentError } = useEmbedSigningContext() || {}; - const { mutateAsync: completeDocument, isPending } = trpc.recipient.completeDocumentWithToken.useMutation(); + const { + mutateAsync: completeDocument, + isPending, + isSuccess, + } = trpc.recipient.completeDocumentWithToken.useMutation(); const { mutateAsync: createDocumentFromDirectTemplate } = trpc.template.createDocumentFromDirectTemplate.useMutation(); @@ -106,11 +110,21 @@ export const EnvelopeSignerCompleteDialog = () => { return; } - analytics.capture('App: Recipient has completed signing', { - signerId: recipient.id, - documentId: envelope.id, - timestamp: new Date().toISOString(), - }); + // The document was already completed by an earlier request (retry, + // stale tab or concurrent submission). Let the user know this click + // didn't complete the document, then continue to the completed page. + if (result.status === 'ALREADY_SIGNED') { + toast({ + title: t`Document already signed`, + description: t`This document was already signed and no further action was taken.`, + }); + } else { + analytics.capture('App: Recipient has completed signing', { + signerId: recipient.id, + documentId: envelope.id, + timestamp: new Date().toISOString(), + }); + } if (onDocumentCompleted) { onDocumentCompleted({ @@ -246,7 +260,7 @@ export const EnvelopeSignerCompleteDialog = () => { return ( { - await tx.recipient.update({ + // Conditional update so two concurrent completion requests can't both + // proceed: only the request that transitions the recipient to SIGNED + // continues, the loser sees a count of 0 and aborts. + const { count: updatedRecipientCount } = await tx.recipient.updateMany({ where: { id: recipient.id, + signingStatus: { + not: SigningStatus.SIGNED, + }, }, data: { signingStatus: SigningStatus.SIGNED, @@ -288,6 +301,16 @@ export const completeDocumentWithToken = async ({ }, }); + // A concurrent request completed the recipient between our initial read + // and this transaction. Abort so the winning request handles all side + // effects, the router resolves this code idempotently. + if (updatedRecipientCount === 0) { + throw new AppError(AppErrorCode.RECIPIENT_ALREADY_SIGNED, { + message: `Recipient ${recipient.id} has already signed`, + statusCode: 400, + }); + } + if (recipientEmail !== recipient.email || recipientName !== recipient.name) { await tx.documentAuditLog.create({ data: createDocumentAuditLogData({ diff --git a/packages/trpc/server/recipient-router/router.ts b/packages/trpc/server/recipient-router/router.ts index e3ba84700..215984706 100644 --- a/packages/trpc/server/recipient-router/router.ts +++ b/packages/trpc/server/recipient-router/router.ts @@ -1,5 +1,5 @@ import { prepareCscRecipientSigning } from '@documenso/ee/server-only/signing/csc/prepare-recipient-signing'; -import { AppError } from '@documenso/lib/errors/app-error'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; 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 { createEnvelopeRecipients } from '@documenso/lib/server-only/recipient/create-envelope-recipients'; @@ -633,6 +633,17 @@ export const recipientRouter = router({ return { status: 'SIGNED' as const }; } catch (err) { + // Resolve retried, stale or concurrent duplicate completion requests + // idempotently so the client routes the user to the completed page + // instead of surfacing an error for a document that is signed. + if (err instanceof AppError && err.code === AppErrorCode.RECIPIENT_ALREADY_SIGNED) { + ctx.logger.info({ + message: 'Recipient attempted to complete a document they have already signed', + }); + + return { status: 'ALREADY_SIGNED' as const }; + } + // Log the error for debugging purposes. ctx.logger.error({ message: 'Error completing document with token', diff --git a/packages/trpc/server/recipient-router/schema.ts b/packages/trpc/server/recipient-router/schema.ts index 2834c2a62..582688f05 100644 --- a/packages/trpc/server/recipient-router/schema.ts +++ b/packages/trpc/server/recipient-router/schema.ts @@ -182,12 +182,16 @@ export type TCompleteDocumentWithTokenMutationSchema = z.infer;