mirror of
https://github.com/documenso/documenso.git
synced 2026-08-15 02:53:32 +10:00
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.
This commit is contained in:
+16
-2
@@ -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;
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<DocumentSigningCompleteDialog
|
||||
isSubmitting={isPending}
|
||||
isSubmitting={isPending || isSuccess}
|
||||
recipientPayload={recipientPayload}
|
||||
onSignatureComplete={isDirectTemplate ? handleDirectTemplateCompleteClick : handleOnCompleteClick}
|
||||
documentTitle={envelope.title}
|
||||
|
||||
@@ -36,6 +36,13 @@ export enum AppErrorCode {
|
||||
*/
|
||||
ENVELOPE_TSP_LOCKED = 'ENVELOPE_TSP_LOCKED',
|
||||
|
||||
/**
|
||||
* A completion request was made for a recipient that has already signed.
|
||||
* Thrown for retried, stale or concurrent duplicate submissions so callers
|
||||
* can resolve them idempotently instead of surfacing an error.
|
||||
*/
|
||||
RECIPIENT_ALREADY_SIGNED = 'RECIPIENT_ALREADY_SIGNED',
|
||||
|
||||
/**
|
||||
* A signer recipient does not have a signature field assigned. Thrown when
|
||||
* distributing an envelope or using a direct template where at least one
|
||||
|
||||
@@ -80,22 +80,29 @@ export const completeDocumentWithToken = async ({
|
||||
|
||||
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
|
||||
|
||||
if (envelope.status !== DocumentStatus.PENDING) {
|
||||
throw new Error(`Document ${envelope.id} must be pending`);
|
||||
}
|
||||
|
||||
if (envelope.recipients.length === 0) {
|
||||
throw new Error(`Document ${envelope.id} has no recipient with token ${token}`);
|
||||
}
|
||||
|
||||
const [recipient] = envelope.recipients;
|
||||
|
||||
assertRecipientNotExpired(recipient);
|
||||
|
||||
// A retried or duplicate completion request for an already signed
|
||||
// recipient throws a code the router resolves idempotently. This must be
|
||||
// checked before the envelope status guard since the envelope may have
|
||||
// been completed and sealed by the recipient's original request.
|
||||
if (recipient.signingStatus === SigningStatus.SIGNED) {
|
||||
throw new Error(`Recipient ${recipient.id} has already signed`);
|
||||
throw new AppError(AppErrorCode.RECIPIENT_ALREADY_SIGNED, {
|
||||
message: `Recipient ${recipient.id} has already signed`,
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
if (envelope.status !== DocumentStatus.PENDING) {
|
||||
throw new Error(`Document ${envelope.id} must be pending`);
|
||||
}
|
||||
|
||||
assertRecipientNotExpired(recipient);
|
||||
|
||||
if (recipient.signingStatus === SigningStatus.REJECTED) {
|
||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
||||
message: 'Recipient has already rejected the document',
|
||||
@@ -276,9 +283,15 @@ export const completeDocumentWithToken = async ({
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
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({
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -182,12 +182,16 @@ export type TCompleteDocumentWithTokenMutationSchema = z.infer<typeof ZCompleteD
|
||||
* Discriminated response: SES envelopes return `{ status: 'SIGNED' }` after
|
||||
* the in-place completion; TSP (AES/QES) envelopes return
|
||||
* `{ status: 'REDIRECT', redirectUrl }` pointing at the credential-scope
|
||||
* OAuth authorize endpoint. Frontend callers can branch on `status` —
|
||||
* existing callers ignored the response and remain compatible.
|
||||
* OAuth authorize endpoint. `{ status: 'ALREADY_SIGNED' }` is returned when
|
||||
* the recipient had already signed prior to this request (retries, stale
|
||||
* tabs, concurrent submissions) so callers can notify the user instead of
|
||||
* erroring. Frontend callers can branch on `status` — existing callers
|
||||
* ignored the response and remain compatible.
|
||||
*/
|
||||
export const ZCompleteDocumentWithTokenResponseSchema = z.discriminatedUnion('status', [
|
||||
z.object({ status: z.literal('REDIRECT'), redirectUrl: z.string() }),
|
||||
z.object({ status: z.literal('SIGNED') }),
|
||||
z.object({ status: z.literal('ALREADY_SIGNED') }),
|
||||
]);
|
||||
|
||||
export type TCompleteDocumentWithTokenResponseSchema = z.infer<typeof ZCompleteDocumentWithTokenResponseSchema>;
|
||||
|
||||
Reference in New Issue
Block a user