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 { onDocumentCompleted, onDocumentError } = useEmbedSigningContext() || {};
|
||||||
|
|
||||||
const { mutateAsync: completeDocument, isPending } = trpc.recipient.completeDocumentWithToken.useMutation();
|
const {
|
||||||
|
mutateAsync: completeDocument,
|
||||||
|
isPending,
|
||||||
|
isSuccess,
|
||||||
|
} = trpc.recipient.completeDocumentWithToken.useMutation();
|
||||||
|
|
||||||
const { mutateAsync: createDocumentFromDirectTemplate } =
|
const { mutateAsync: createDocumentFromDirectTemplate } =
|
||||||
trpc.template.createDocumentFromDirectTemplate.useMutation();
|
trpc.template.createDocumentFromDirectTemplate.useMutation();
|
||||||
@@ -106,11 +110,21 @@ export const EnvelopeSignerCompleteDialog = () => {
|
|||||||
return;
|
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', {
|
analytics.capture('App: Recipient has completed signing', {
|
||||||
signerId: recipient.id,
|
signerId: recipient.id,
|
||||||
documentId: envelope.id,
|
documentId: envelope.id,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (onDocumentCompleted) {
|
if (onDocumentCompleted) {
|
||||||
onDocumentCompleted({
|
onDocumentCompleted({
|
||||||
@@ -246,7 +260,7 @@ export const EnvelopeSignerCompleteDialog = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DocumentSigningCompleteDialog
|
<DocumentSigningCompleteDialog
|
||||||
isSubmitting={isPending}
|
isSubmitting={isPending || isSuccess}
|
||||||
recipientPayload={recipientPayload}
|
recipientPayload={recipientPayload}
|
||||||
onSignatureComplete={isDirectTemplate ? handleDirectTemplateCompleteClick : handleOnCompleteClick}
|
onSignatureComplete={isDirectTemplate ? handleDirectTemplateCompleteClick : handleOnCompleteClick}
|
||||||
documentTitle={envelope.title}
|
documentTitle={envelope.title}
|
||||||
|
|||||||
@@ -36,6 +36,13 @@ export enum AppErrorCode {
|
|||||||
*/
|
*/
|
||||||
ENVELOPE_TSP_LOCKED = 'ENVELOPE_TSP_LOCKED',
|
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
|
* A signer recipient does not have a signature field assigned. Thrown when
|
||||||
* distributing an envelope or using a direct template where at least one
|
* 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);
|
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
|
||||||
|
|
||||||
if (envelope.status !== DocumentStatus.PENDING) {
|
|
||||||
throw new Error(`Document ${envelope.id} must be pending`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (envelope.recipients.length === 0) {
|
if (envelope.recipients.length === 0) {
|
||||||
throw new Error(`Document ${envelope.id} has no recipient with token ${token}`);
|
throw new Error(`Document ${envelope.id} has no recipient with token ${token}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [recipient] = envelope.recipients;
|
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) {
|
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) {
|
if (recipient.signingStatus === SigningStatus.REJECTED) {
|
||||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
||||||
message: 'Recipient has already rejected the document',
|
message: 'Recipient has already rejected the document',
|
||||||
@@ -276,9 +283,15 @@ export const completeDocumentWithToken = async ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
await prisma.$transaction(async (tx) => {
|
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: {
|
where: {
|
||||||
id: recipient.id,
|
id: recipient.id,
|
||||||
|
signingStatus: {
|
||||||
|
not: SigningStatus.SIGNED,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
signingStatus: SigningStatus.SIGNED,
|
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) {
|
if (recipientEmail !== recipient.email || recipientName !== recipient.name) {
|
||||||
await tx.documentAuditLog.create({
|
await tx.documentAuditLog.create({
|
||||||
data: createDocumentAuditLogData({
|
data: createDocumentAuditLogData({
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { prepareCscRecipientSigning } from '@documenso/ee/server-only/signing/csc/prepare-recipient-signing';
|
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 { completeDocumentWithToken } from '@documenso/lib/server-only/document/complete-document-with-token';
|
||||||
import { rejectDocumentWithToken } from '@documenso/lib/server-only/document/reject-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';
|
import { createEnvelopeRecipients } from '@documenso/lib/server-only/recipient/create-envelope-recipients';
|
||||||
@@ -633,6 +633,17 @@ export const recipientRouter = router({
|
|||||||
|
|
||||||
return { status: 'SIGNED' as const };
|
return { status: 'SIGNED' as const };
|
||||||
} catch (err) {
|
} 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.
|
// Log the error for debugging purposes.
|
||||||
ctx.logger.error({
|
ctx.logger.error({
|
||||||
message: 'Error completing document with token',
|
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
|
* Discriminated response: SES envelopes return `{ status: 'SIGNED' }` after
|
||||||
* the in-place completion; TSP (AES/QES) envelopes return
|
* the in-place completion; TSP (AES/QES) envelopes return
|
||||||
* `{ status: 'REDIRECT', redirectUrl }` pointing at the credential-scope
|
* `{ status: 'REDIRECT', redirectUrl }` pointing at the credential-scope
|
||||||
* OAuth authorize endpoint. Frontend callers can branch on `status` —
|
* OAuth authorize endpoint. `{ status: 'ALREADY_SIGNED' }` is returned when
|
||||||
* existing callers ignored the response and remain compatible.
|
* 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', [
|
export const ZCompleteDocumentWithTokenResponseSchema = z.discriminatedUnion('status', [
|
||||||
z.object({ status: z.literal('REDIRECT'), redirectUrl: z.string() }),
|
z.object({ status: z.literal('REDIRECT'), redirectUrl: z.string() }),
|
||||||
z.object({ status: z.literal('SIGNED') }),
|
z.object({ status: z.literal('SIGNED') }),
|
||||||
|
z.object({ status: z.literal('ALREADY_SIGNED') }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export type TCompleteDocumentWithTokenResponseSchema = z.infer<typeof ZCompleteDocumentWithTokenResponseSchema>;
|
export type TCompleteDocumentWithTokenResponseSchema = z.infer<typeof ZCompleteDocumentWithTokenResponseSchema>;
|
||||||
|
|||||||
Reference in New Issue
Block a user