Merge branch 'main' into feature/pdf-placeholder-selection-fields

This commit is contained in:
Catalin Pit
2026-08-19 13:17:49 +03:00
committed by GitHub
44 changed files with 3989 additions and 5009 deletions
+18
View File
@@ -43,6 +43,20 @@ export enum AppErrorCode {
*/
RECIPIENT_ALREADY_SIGNED = 'RECIPIENT_ALREADY_SIGNED',
/**
* A completion request was made for a recipient that still has required
* fields which have not been inserted. Usually indicates the client's field
* state is out of sync with the server (e.g. a field insert failed to
* persist before submission).
*/
RECIPIENT_HAS_UNSIGNED_FIELDS = 'RECIPIENT_HAS_UNSIGNED_FIELDS',
/**
* A completion request was made by a recipient in a sequential signing flow
* before the preceding recipients have signed.
*/
RECIPIENT_OUT_OF_TURN = 'RECIPIENT_OUT_OF_TURN',
/**
* A signer recipient does not have a signature field assigned. Thrown when
* distributing an envelope or using a direct template where at least one
@@ -99,6 +113,8 @@ export const genericErrorCodeToTrpcErrorCodeMap: Record<string, { code: string;
[AppErrorCode.ENVELOPE_LEGACY]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.ENVELOPE_TSP_LOCKED]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.MISSING_SIGNATURE_FIELD]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.RECIPIENT_HAS_UNSIGNED_FIELDS]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.RECIPIENT_OUT_OF_TURN]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.CSC_INSTANCE_MODE_MISMATCH]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.CSC_UNLICENSED]: { code: 'FORBIDDEN', status: 403 },
[AppErrorCode.CSC_PROVIDER_INFO_FAILED]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
@@ -307,6 +323,8 @@ export class AppError extends Error {
AppErrorCode.ENVELOPE_LEGACY,
AppErrorCode.ENVELOPE_TSP_LOCKED,
AppErrorCode.MISSING_SIGNATURE_FIELD,
AppErrorCode.RECIPIENT_HAS_UNSIGNED_FIELDS,
AppErrorCode.RECIPIENT_OUT_OF_TURN,
AppErrorCode.CSC_INSTANCE_MODE_MISMATCH,
AppErrorCode.CSC_CREDENTIAL_LIST_EMPTY,
AppErrorCode.CSC_CERT_INVALID,
+3 -3
View File
@@ -15,7 +15,7 @@
"clean": "rimraf node_modules"
},
"dependencies": {
"@ai-sdk/google-vertex": "3.0.81",
"@ai-sdk/google-vertex": "5.0.48",
"@aws-sdk/client-s3": "^3.998.0",
"@aws-sdk/client-sesv2": "^3.998.0",
"@aws-sdk/cloudfront-signer": "^3.998.0",
@@ -43,7 +43,7 @@
"@sindresorhus/slugify": "^3.0.0",
"@team-plain/typescript-sdk": "^5.11.0",
"@vvo/tzdb": "^6.196.0",
"ai": "^5.0.104",
"ai": "^7.0.58",
"bullmq": "^5.71.1",
"colord": "^2.9.3",
"csv-parse": "^6.1.0",
@@ -67,7 +67,7 @@
"posthog-node": "4.18.0",
"react": "^19.2.7",
"remeda": "^2.32.0",
"sharp": "0.34.5",
"sharp": "0.35.3",
"stripe": "^12.18.0",
"ts-pattern": "^5.9.0",
"zod": "^3.25.76"
@@ -59,7 +59,7 @@ export const completeDocumentWithToken = async ({
nextSigner,
recipientOverride,
}: CompleteDocumentWithTokenOptions) => {
const envelope = await prisma.envelope.findFirstOrThrow({
const envelope = await prisma.envelope.findFirst({
where: {
...unsafeBuildEnvelopeIdQuery(id, EnvelopeType.DOCUMENT),
recipients: {
@@ -78,10 +78,23 @@ export const completeDocumentWithToken = async ({
},
});
// The most common cause is a stale signing page: the document was deleted,
// or the recipient was removed, after the link was opened. Surface a
// NOT_FOUND instead of leaking a Prisma P2025 as a 500.
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found for the provided signing token',
statusCode: 404,
});
}
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
if (envelope.recipients.length === 0) {
throw new Error(`Document ${envelope.id} has no recipient with token ${token}`);
throw new AppError(AppErrorCode.NOT_FOUND, {
message: `Document ${envelope.id} has no recipient with the provided token`,
statusCode: 404,
});
}
const [recipient] = envelope.recipients;
@@ -98,7 +111,19 @@ export const completeDocumentWithToken = async ({
}
if (envelope.status !== DocumentStatus.PENDING) {
throw new Error(`Document ${envelope.id} must be pending`);
const envelopeStatusErrorCode: Record<DocumentStatus, AppErrorCode> = {
[DocumentStatus.DRAFT]: AppErrorCode.ENVELOPE_DRAFT,
[DocumentStatus.COMPLETED]: AppErrorCode.ENVELOPE_COMPLETED,
[DocumentStatus.REJECTED]: AppErrorCode.ENVELOPE_REJECTED,
[DocumentStatus.CANCELLED]: AppErrorCode.ENVELOPE_CANCELLED,
// Unreachable: guarded by the status check above.
[DocumentStatus.PENDING]: AppErrorCode.INVALID_REQUEST,
};
throw new AppError(envelopeStatusErrorCode[envelope.status], {
message: `Document ${envelope.id} must be pending to be completed, found ${envelope.status}`,
statusCode: 400,
});
}
assertRecipientNotExpired(recipient);
@@ -116,7 +141,10 @@ export const completeDocumentWithToken = async ({
});
if (!isRecipientsTurn) {
throw new Error(`Recipient ${recipient.id} attempted to complete the document before it was their turn`);
throw new AppError(AppErrorCode.RECIPIENT_OUT_OF_TURN, {
message: `Recipient ${recipient.id} attempted to complete the document before it was their turn`,
statusCode: 400,
});
}
}
@@ -279,7 +307,10 @@ export const completeDocumentWithToken = async ({
}
if (fieldsContainUnsignedRequiredField(fields)) {
throw new Error(`Recipient ${recipient.id} has unsigned fields`);
throw new AppError(AppErrorCode.RECIPIENT_HAS_UNSIGNED_FIELDS, {
message: `Recipient ${recipient.id} has unsigned fields`,
statusCode: 400,
});
}
await prisma.$transaction(async (tx) => {
@@ -8,13 +8,14 @@ export const cancelEnvelopeMeta: TrpcRouteMeta = {
method: 'POST',
path: '/envelope/cancel',
summary: 'Cancel envelope',
description: 'Cancel a pending envelope',
tags: ['Envelope'],
},
};
export const ZCancelEnvelopeRequestSchema = z.object({
envelopeId: z.string(),
reason: z.string().optional(),
envelopeId: z.string().describe('The ID of the envelope to cancel.'),
reason: z.string().describe('The reason for cancelling the envelope.').optional(),
});
export const ZCancelEnvelopeResponseSchema = ZSuccessResponseSchema;
@@ -8,12 +8,13 @@ export const deleteEnvelopeMeta: TrpcRouteMeta = {
method: 'POST',
path: '/envelope/delete',
summary: 'Delete envelope',
description: 'Delete an envelope',
tags: ['Envelope'],
},
};
export const ZDeleteEnvelopeRequestSchema = z.object({
envelopeId: z.string(),
envelopeId: z.string().describe('The ID of the envelope to delete.'),
});
export const ZDeleteEnvelopeResponseSchema = ZSuccessResponseSchema;
@@ -12,24 +12,32 @@ export const updateEnvelopeMeta: TrpcRouteMeta = {
method: 'POST',
path: '/envelope/update',
summary: 'Update envelope',
description: 'Update envelope properties and settings',
tags: ['Envelope'],
},
};
export const ZUpdateEnvelopeRequestSchema = z.object({
envelopeId: z.string(),
envelopeId: z.string().describe('The ID of the envelope to update.'),
data: z
.object({
title: ZDocumentTitleSchema.optional(),
externalId: ZDocumentExternalIdSchema.nullish(),
visibility: ZDocumentVisibilitySchema.optional(),
globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema).optional(),
globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional(),
folderId: z.string().nullish(),
templateType: z.nativeEnum(TemplateType).optional(),
globalAccessAuth: z
.array(ZDocumentAccessAuthTypesSchema)
.describe('The authentication methods required to access the envelope.')
.optional(),
globalActionAuth: z
.array(ZDocumentActionAuthTypesSchema)
.describe('The authentication methods required to sign the envelope.')
.optional(),
folderId: z.string().describe('The ID of the folder containing the envelope.').nullish(),
templateType: z.nativeEnum(TemplateType).describe('The template type.').optional(),
})
.describe('The envelope properties to update.')
.optional(),
meta: ZDocumentMetaUpdateSchema.optional(),
meta: ZDocumentMetaUpdateSchema.describe('The email and signing settings to update.').optional(),
});
export const ZUpdateEnvelopeResponseSchema = ZEnvelopeLiteSchema;
@@ -603,7 +603,7 @@ export const recipientRouter = router({
// can't complete via this route — they go through the CSC sync sign
// flow (`enterprise.csc.signEnvelope`). This route returns the redirect URL
// for the credential-scope OAuth round-trip.
const envelope = await prisma.envelope.findFirstOrThrow({
const envelope = await prisma.envelope.findFirst({
where: {
...unsafeBuildEnvelopeIdQuery({ type: 'documentId', id: documentId }, EnvelopeType.DOCUMENT),
recipients: { some: { token } },
@@ -611,6 +611,16 @@ export const recipientRouter = router({
select: { signatureLevel: true, internalVersion: true },
});
// The most common cause is a stale signing page: the document was
// deleted, or the recipient was removed, after the link was opened.
// Surface a NOT_FOUND instead of leaking a Prisma P2025 as a 500.
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found for the provided signing token',
statusCode: 404,
});
}
if (isTspEnvelope(envelope)) {
return await prepareCscRecipientSigning({
recipientToken: token,
@@ -2,16 +2,27 @@ import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-c
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { Trans, useLingui } from '@lingui/react/macro';
import type { Field, Recipient } from '@prisma/client';
import { SigningStatus } from '@prisma/client';
import { ClockIcon, EyeOffIcon, LockIcon } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { FieldType, SigningStatus } from '@prisma/client';
import {
CalendarDaysIcon,
CheckSquareIcon,
ChevronDownIcon,
ContactIcon,
DiscIcon,
EyeOffIcon,
HashIcon,
LockIcon,
MailIcon,
TypeIcon,
UserIcon,
} from 'lucide-react';
import { type ElementType, useCallback, useEffect, useState } from 'react';
import { isTemplateRecipientEmailPlaceholder } from '../../../lib/constants/template';
import { extractInitials } from '../../../lib/utils/recipient-formatter';
import { SignatureIcon } from '../../icons/signature';
import { cn } from '../../lib/utils';
import { Avatar, AvatarFallback } from '../../primitives/avatar';
import { Badge } from '../../primitives/badge';
import { FRIENDLY_FIELD_TYPE } from '../../primitives/document-flow/types';
import { PopoverHover } from '../../primitives/popover';
@@ -27,16 +38,18 @@ interface EnvelopeRecipientFieldTooltipProps {
showRecipientColors?: boolean;
}
const getRecipientDisplayText = (recipient: { name: string; email: string }) => {
if (recipient.name && !isTemplateRecipientEmailPlaceholder(recipient.email)) {
return `${recipient.name} (${recipient.email})`;
}
if (recipient.name && isTemplateRecipientEmailPlaceholder(recipient.email)) {
return recipient.name;
}
return recipient.email;
const FIELD_TYPE_ICONS: Record<FieldType, ElementType> = {
[FieldType.SIGNATURE]: SignatureIcon,
[FieldType.FREE_SIGNATURE]: SignatureIcon,
[FieldType.INITIALS]: ContactIcon,
[FieldType.TEXT]: TypeIcon,
[FieldType.DATE]: CalendarDaysIcon,
[FieldType.EMAIL]: MailIcon,
[FieldType.NAME]: UserIcon,
[FieldType.NUMBER]: HashIcon,
[FieldType.RADIO]: DiscIcon,
[FieldType.CHECKBOX]: CheckSquareIcon,
[FieldType.DROPDOWN]: ChevronDownIcon,
};
/**
@@ -50,6 +63,8 @@ export function EnvelopeRecipientFieldTooltip({
}: EnvelopeRecipientFieldTooltipProps) {
const { t } = useLingui();
const FieldIcon = FIELD_TYPE_ICONS[field.type];
const [hideField, setHideField] = useState<boolean>(!showRecipientTooltip);
const [coords, setCoords] = useState({
@@ -138,54 +153,64 @@ export function EnvelopeRecipientFieldTooltip({
</Avatar>
}
contentProps={{
className: 'relative flex mb-4 w-fit flex-col p-4 text-sm',
className: 'flex w-64 flex-col overflow-hidden p-0 text-sm',
sideOffset: 20,
onOpenAutoFocus: (event) => event.preventDefault(),
}}
>
{showFieldStatus && (
<Badge
className="mx-auto mb-1 py-0.5"
variant={
field?.fieldMeta?.readOnly
? 'neutral'
: field.recipient.signingStatus === SigningStatus.SIGNED
? 'default'
: 'secondary'
}
>
{field?.fieldMeta?.readOnly ? (
<>
<LockIcon className="mr-1 h-3 w-3" />
<Trans>Read Only</Trans>
</>
) : field.recipient.signingStatus === SigningStatus.SIGNED ? (
<>
<SignatureIcon className="mr-1 h-3 w-3" />
<Trans>Signed</Trans>
</>
) : (
<>
<ClockIcon className="mr-1 h-3 w-3" />
<Trans>Pending</Trans>
</>
)}
</Badge>
)}
<div className="flex items-center gap-2 p-3">
<FieldIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
<p className="text-center font-semibold">
<span>
<p className="min-w-0 flex-1 truncate font-medium">
<Trans>{t(FRIENDLY_FIELD_TYPE[field.type])} field</Trans>
</span>
</p>
</p>
<p className="mt-1 text-center text-muted-foreground text-xs">{getRecipientDisplayText(field.recipient)}</p>
{showFieldStatus && (
<div className="flex shrink-0 items-center gap-1.5 text-xs">
{field?.fieldMeta?.readOnly ? (
<>
<LockIcon className="h-3 w-3 text-muted-foreground" />
<span className="text-muted-foreground">
<Trans>Read Only</Trans>
</span>
</>
) : field.recipient.signingStatus === SigningStatus.SIGNED ? (
<>
<span className="h-1.5 w-1.5 rounded-full bg-green-500" />
<span className="text-green-600 dark:text-green-400">
<Trans>Signed</Trans>
</span>
</>
) : (
<>
<span className="h-1.5 w-1.5 rounded-full bg-amber-400" />
<span className="text-amber-600 dark:text-amber-400">
<Trans>Pending</Trans>
</span>
</>
)}
</div>
)}
</div>
<button
className="absolute top-0 right-0 my-1 p-2 focus:outline-none focus-visible:ring-0"
onClick={() => setHideField(true)}
title="Hide field"
>
<EyeOffIcon className="h-3 w-3" />
</button>
<div className="flex items-center gap-3 border-border/50 border-t bg-muted/50 px-3 py-2.5">
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-xs">{field.recipient.name || field.recipient.email}</p>
{!isTemplateRecipientEmailPlaceholder(field.recipient.email) && field.recipient.name && (
<p className="truncate text-muted-foreground text-xs">{field.recipient.email}</p>
)}
</div>
<button
type="button"
className="-m-1 shrink-0 rounded-sm p-1 text-muted-foreground hover:bg-background hover:text-foreground"
onClick={() => setHideField(true)}
title={t`Hide field`}
>
<EyeOffIcon className="h-3.5 w-3.5" />
</button>
</div>
</PopoverHover>
</div>
);