Compare commits

..

29 Commits

Author SHA1 Message Date
15e2988f9e Merge branch 'main' into feat/auto-placing-fields 2025-11-20 09:32:13 +02:00
17c6098638 v2.0.14 2025-11-20 15:12:40 +11:00
e5bde53ee4 chore: add translations (#2223) 2025-11-20 15:09:13 +11:00
0663605ffd fix: handle loading files in embedded authoring update flows (#2218) 2025-11-20 15:07:41 +11:00
1bbe561162 chore: add pending ui to signing completion page (#2224)
Adds a pending UI state to the signing completion page for when all
recipients have finished signing but the document hasn't completed the
sealing background job.

<img width="695" height="562" alt="image"
src="https://github.com/user-attachments/assets/b015bc38-9489-4baa-ac0a-07cb1ac24b25"
/>
2025-11-20 15:07:26 +11:00
fbc156722a feat: add Portuguese (Brazil) translation support version 2.0.6 (#2165)
Portuguese (Brazil) Translation Support for Documenso
2025-11-20 14:14:47 +11:00
f5d63fb76c feat: add option to change or disable OIDC login prompt parameter (#2037) 2025-11-20 13:08:36 +11:00
374477e692 refactor: improve layout of completed signing page (#2209) 2025-11-20 11:04:41 +11:00
11d9bde8f8 fix: improve sealing speed (#2210) 2025-11-19 14:15:12 +11:00
86d11cc720 Merge branch 'main' into feat/auto-placing-fields 2025-11-17 12:14:28 +02:00
c615e30633 chore: enhance envelope creation with placeholder extraction and removal 2025-11-10 10:08:59 +02:00
358ba2dd6f chore: update envelope handling and improve field positioning in PDF processing 2025-11-07 14:10:46 +02:00
7fa86fe297 Merge branch 'feat/auto-placing-fields' of github.com:documenso/documenso into feat/auto-placing-fields 2025-11-07 10:36:25 +02:00
0b91d33bfb chore: enhance recipient handling and streamline placeholder processing in PDF fields 2025-11-07 10:35:59 +02:00
e010238bcc Merge branch 'main' into feat/auto-placing-fields 2025-11-07 08:57:07 +02:00
498a2be1c7 chore: modularize PDF processing by extracting helper functions 2025-11-06 10:20:04 +02:00
3e84aa632f chore: improve recipient creation with pattern matching and helper function 2025-11-06 09:29:08 +02:00
a08a77e98b refactor: improve variable naming and streamline placeholder extraction logic in PDF processing 2025-11-04 14:18:01 +02:00
13d9ca7a0e chore: some cleanup 2025-11-04 11:16:53 +02:00
d25565b7d0 test: add end-to-end tests for auto-placing fields from PDF placeholders 2025-11-03 16:07:09 +02:00
91421a7d62 Merge branch 'main' into feat/auto-placing-fields 2025-11-03 14:31:28 +02:00
a9f1e39b10 feat: implement envelope item processing and enhance final envelope retrieval 2025-11-03 12:42:47 +02:00
b37748654e feat: enhance recipient placeholder extraction and management in PDF processing 2025-11-03 12:23:11 +02:00
b3ed80d721 feat: integrate placeholder replacement in PDF normalization process 2025-10-31 13:12:23 +02:00
b3cb750470 feat: refactor field metadata handling and enhance field type parsing in PDF processing 2025-10-31 11:41:34 +02:00
1e52493144 feat: fieldMeta parsing 2025-10-30 16:43:54 +02:00
ab95e80987 feat: enhance PDF placeholder handling and recipient management 2025-10-30 13:52:18 +02:00
1780a5c262 Merge branch 'main' into feat/auto-placing-fields 2025-10-29 14:29:04 +02:00
cb9bf407f7 feat: autoplace fields from placeholders 2025-10-28 13:50:06 +02:00
30 changed files with 12199 additions and 288 deletions

View File

@ -23,6 +23,10 @@ NEXT_PRIVATE_OIDC_CLIENT_ID=""
NEXT_PRIVATE_OIDC_CLIENT_SECRET="" NEXT_PRIVATE_OIDC_CLIENT_SECRET=""
NEXT_PRIVATE_OIDC_PROVIDER_LABEL="OIDC" NEXT_PRIVATE_OIDC_PROVIDER_LABEL="OIDC"
NEXT_PRIVATE_OIDC_SKIP_VERIFY="" NEXT_PRIVATE_OIDC_SKIP_VERIFY=""
# Specifies the prompt to use for OIDC signin, explicitly setting
# an empty string will omit the prompt parameter.
# See: https://www.cerberauth.com/blog/openid-connect-oauth2-prompts/
NEXT_PRIVATE_OIDC_PROMPT="login"
# [[URLS]] # [[URLS]]
NEXT_PUBLIC_WEBAPP_URL="http://localhost:3000" NEXT_PUBLIC_WEBAPP_URL="http://localhost:3000"

View File

@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import type { DocumentData, FieldType } from '@prisma/client'; import type { EnvelopeItem, FieldType } from '@prisma/client';
import { ReadStatus, type Recipient, SendStatus, SigningStatus } from '@prisma/client'; import { ReadStatus, type Recipient, SendStatus, SigningStatus } from '@prisma/client';
import { base64 } from '@scure/base'; import { base64 } from '@scure/base';
import { ChevronsUpDown } from 'lucide-react'; import { ChevronsUpDown } from 'lucide-react';
@ -40,7 +40,8 @@ const DEFAULT_WIDTH_PX = MIN_WIDTH_PX * 2.5;
export type ConfigureFieldsViewProps = { export type ConfigureFieldsViewProps = {
configData: TConfigureEmbedFormSchema; configData: TConfigureEmbedFormSchema;
documentData?: DocumentData; presignToken?: string | undefined;
envelopeItem?: Pick<EnvelopeItem, 'id' | 'envelopeId'>;
defaultValues?: Partial<TConfigureFieldsFormSchema>; defaultValues?: Partial<TConfigureFieldsFormSchema>;
onBack?: (data: TConfigureFieldsFormSchema) => void; onBack?: (data: TConfigureFieldsFormSchema) => void;
onSubmit: (data: TConfigureFieldsFormSchema) => void; onSubmit: (data: TConfigureFieldsFormSchema) => void;
@ -48,7 +49,8 @@ export type ConfigureFieldsViewProps = {
export const ConfigureFieldsView = ({ export const ConfigureFieldsView = ({
configData, configData,
documentData, presignToken,
envelopeItem,
defaultValues, defaultValues,
onBack, onBack,
onSubmit, onSubmit,
@ -82,17 +84,25 @@ export const ConfigureFieldsView = ({
}, []); }, []);
const normalizedDocumentData = useMemo(() => { const normalizedDocumentData = useMemo(() => {
if (documentData) { if (envelopeItem) {
return documentData.data; return undefined;
} }
if (!configData.documentData) { if (!configData.documentData) {
return null; return undefined;
} }
return base64.encode(configData.documentData.data); return base64.encode(configData.documentData.data);
}, [configData.documentData]); }, [configData.documentData]);
const normalizedEnvelopeItem = useMemo(() => {
if (envelopeItem) {
return envelopeItem;
}
return { id: '', envelopeId: '' };
}, [envelopeItem]);
const recipients = useMemo(() => { const recipients = useMemo(() => {
return configData.signers.map<Recipient>((signer, index) => ({ return configData.signers.map<Recipient>((signer, index) => ({
id: signer.nativeId || index, id: signer.nativeId || index,
@ -534,14 +544,11 @@ export const ConfigureFieldsView = ({
)} )}
<Form {...form}> <Form {...form}>
{normalizedDocumentData && (
<div> <div>
<PDFViewer <PDFViewer
presignToken={presignToken}
overrideData={normalizedDocumentData} overrideData={normalizedDocumentData}
envelopeItem={{ envelopeItem={normalizedEnvelopeItem}
id: '',
envelopeId: '',
}}
token={undefined} token={undefined}
version="signed" version="signed"
/> />
@ -550,9 +557,7 @@ export const ConfigureFieldsView = ({
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`} target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}
> >
{localFields.map((field, index) => { {localFields.map((field, index) => {
const recipientIndex = recipients.findIndex( const recipientIndex = recipients.findIndex((r) => r.id === field.recipientId);
(r) => r.id === field.recipientId,
);
return ( return (
<FieldItem <FieldItem
@ -583,7 +588,6 @@ export const ConfigureFieldsView = ({
})} })}
</ElementVisible> </ElementVisible>
</div> </div>
)}
</Form> </Form>
</div> </div>
</div> </div>

View File

@ -1,10 +1,8 @@
import { useEffect } from 'react';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client'; import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
import { CheckCircle2, Clock8, DownloadIcon } from 'lucide-react'; import { CheckCircle2, Clock8, DownloadIcon, Loader2 } from 'lucide-react';
import { Link, useRevalidator } from 'react-router'; import { Link } from 'react-router';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import signingCelebration from '@documenso/assets/images/signing-celebration.png'; import signingCelebration from '@documenso/assets/images/signing-celebration.png';
@ -18,7 +16,7 @@ import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email'; import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
import { isDocumentCompleted } from '@documenso/lib/utils/document'; import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { env } from '@documenso/lib/utils/env'; import { env } from '@documenso/lib/utils/env';
import type { Document } from '@documenso/prisma/types/document-legacy-schema'; import { trpc } from '@documenso/trpc/react';
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button'; import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
import { SigningCard3D } from '@documenso/ui/components/signing-card'; import { SigningCard3D } from '@documenso/ui/components/signing-card';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
@ -84,6 +82,13 @@ export async function loader({ params, request }: Route.LoaderArgs) {
const canSignUp = !isExistingUser && env('NEXT_PUBLIC_DISABLE_SIGNUP') !== 'true'; const canSignUp = !isExistingUser && env('NEXT_PUBLIC_DISABLE_SIGNUP') !== 'true';
const canRedirectToFolder =
user && document.userId === user.id && document.folderId && document.team?.url;
const returnToHomePath = canRedirectToFolder
? `/t/${document.team.url}/documents/f/${document.folderId}`
: '/';
return { return {
isDocumentAccessValid: true, isDocumentAccessValid: true,
canSignUp, canSignUp,
@ -92,6 +97,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
signatures, signatures,
document, document,
recipient, recipient,
returnToHomePath,
}; };
} }
@ -109,8 +115,27 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
document, document,
recipient, recipient,
recipientEmail, recipientEmail,
returnToHomePath,
} = loaderData; } = loaderData;
// Poll signing status every few seconds
const { data: signingStatusData } = trpc.envelope.signingStatus.useQuery(
{
token: recipient?.token || '',
},
{
refetchInterval: 3000,
initialData: match(document?.status)
.with(DocumentStatus.COMPLETED, () => ({ status: 'COMPLETED' }) as const)
.with(DocumentStatus.REJECTED, () => ({ status: 'REJECTED' }) as const)
.with(DocumentStatus.PENDING, () => ({ status: 'PENDING' }) as const)
.otherwise(() => ({ status: 'PENDING' }) as const),
},
);
// Use signing status from query if available, otherwise fall back to document status
const signingStatus = signingStatusData?.status ?? 'PENDING';
if (!isDocumentAccessValid) { if (!isDocumentAccessValid) {
return <DocumentSigningAuthPageView email={recipientEmail} />; return <DocumentSigningAuthPageView email={recipientEmail} />;
} }
@ -118,7 +143,7 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
return ( return (
<div <div
className={cn( className={cn(
'-mx-4 flex flex-col items-center overflow-hidden px-4 pt-24 md:-mx-8 md:px-8 lg:pt-36 xl:pt-44', '-mx-4 flex flex-col items-center overflow-hidden px-4 pt-16 md:-mx-8 md:px-8 lg:pt-20 xl:pt-28',
{ 'pt-0 lg:pt-0 xl:pt-0': canSignUp }, { 'pt-0 lg:pt-0 xl:pt-0': canSignUp },
)} )}
> >
@ -152,8 +177,8 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
{recipient.role === RecipientRole.APPROVER && <Trans>Document Approved</Trans>} {recipient.role === RecipientRole.APPROVER && <Trans>Document Approved</Trans>}
</h2> </h2>
{match({ status: document.status, deletedAt: document.deletedAt }) {match({ status: signingStatus, deletedAt: document.deletedAt })
.with({ status: DocumentStatus.COMPLETED }, () => ( .with({ status: 'COMPLETED' }, () => (
<div className="text-documenso-700 mt-4 flex items-center text-center"> <div className="text-documenso-700 mt-4 flex items-center text-center">
<CheckCircle2 className="mr-2 h-5 w-5" /> <CheckCircle2 className="mr-2 h-5 w-5" />
<span className="text-sm"> <span className="text-sm">
@ -161,6 +186,14 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
</span> </span>
</div> </div>
)) ))
.with({ status: 'PROCESSING' }, () => (
<div className="mt-4 flex items-center text-center text-orange-600">
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
<span className="text-sm">
<Trans>Processing document</Trans>
</span>
</div>
))
.with({ deletedAt: null }, () => ( .with({ deletedAt: null }, () => (
<div className="mt-4 flex items-center text-center text-blue-600"> <div className="mt-4 flex items-center text-center text-blue-600">
<Clock8 className="mr-2 h-5 w-5" /> <Clock8 className="mr-2 h-5 w-5" />
@ -178,14 +211,22 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
</div> </div>
))} ))}
{match({ status: document.status, deletedAt: document.deletedAt }) {match({ status: signingStatus, deletedAt: document.deletedAt })
.with({ status: DocumentStatus.COMPLETED }, () => ( .with({ status: 'COMPLETED' }, () => (
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base"> <p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
<Trans> <Trans>
Everyone has signed! You will receive an Email copy of the signed document. Everyone has signed! You will receive an Email copy of the signed document.
</Trans> </Trans>
</p> </p>
)) ))
.with({ status: 'PROCESSING' }, () => (
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
<Trans>
All recipients have signed. The document is being processed and you will receive
an Email copy shortly.
</Trans>
</p>
))
.with({ deletedAt: null }, () => ( .with({ deletedAt: null }, () => (
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base"> <p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
<Trans> <Trans>
@ -202,23 +243,35 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
</p> </p>
))} ))}
<div className="mt-8 flex w-full max-w-sm items-center justify-center gap-4"> <div className="mt-8 flex w-full max-w-xs flex-col items-stretch gap-4 md:w-auto md:max-w-none md:flex-row md:items-center">
<DocumentShareButton documentId={document.id} token={recipient.token} /> <DocumentShareButton
documentId={document.id}
token={recipient.token}
className="w-full max-w-none md:flex-1"
/>
{isDocumentCompleted(document.status) && ( {isDocumentCompleted(document) && (
<EnvelopeDownloadDialog <EnvelopeDownloadDialog
envelopeId={document.envelopeId} envelopeId={document.envelopeId}
envelopeStatus={document.status} envelopeStatus={document.status}
envelopeItems={document.envelopeItems} envelopeItems={document.envelopeItems}
token={recipient?.token} token={recipient?.token}
trigger={ trigger={
<Button type="button" variant="outline" className="flex-1"> <Button type="button" variant="outline" className="flex-1 md:flex-initial">
<DownloadIcon className="mr-2 h-5 w-5" /> <DownloadIcon className="mr-2 h-5 w-5" />
<Trans>Download</Trans> <Trans>Download</Trans>
</Button> </Button>
} }
/> />
)} )}
{user && (
<Button asChild>
<Link to={returnToHomePath}>
<Trans>Go Back Home</Trans>
</Link>
</Button>
)}
</div> </div>
</div> </div>
@ -238,41 +291,8 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
<ClaimAccount defaultName={recipientName} defaultEmail={recipient.email} /> <ClaimAccount defaultName={recipientName} defaultEmail={recipient.email} />
</div> </div>
)} )}
{user && (
<Link to="/" className="text-documenso-700 hover:text-documenso-600 mt-2">
<Trans>Go Back Home</Trans>
</Link>
)}
</div> </div>
</div> </div>
<PollUntilDocumentCompleted document={document} />
</div> </div>
); );
} }
export type PollUntilDocumentCompletedProps = {
document: Pick<Document, 'id' | 'status' | 'deletedAt'>;
};
export const PollUntilDocumentCompleted = ({ document }: PollUntilDocumentCompletedProps) => {
const { revalidate } = useRevalidator();
useEffect(() => {
if (isDocumentCompleted(document.status)) {
return;
}
const interval = setInterval(() => {
if (window.document.hasFocus()) {
void revalidate();
}
}, 5000);
return () => clearInterval(interval);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [document.status]);
return <></>;
};

View File

@ -75,6 +75,7 @@ export const loader = async ({ request, params }: Route.LoaderArgs) => {
})); }));
return { return {
token,
document: { document: {
...document, ...document,
fields, fields,
@ -86,7 +87,7 @@ export default function EmbeddingAuthoringDocumentEditPage() {
const { _ } = useLingui(); const { _ } = useLingui();
const { toast } = useToast(); const { toast } = useToast();
const { document } = useLoaderData<typeof loader>(); const { document, token } = useLoaderData<typeof loader>();
const [hasFinishedInit, setHasFinishedInit] = useState(false); const [hasFinishedInit, setHasFinishedInit] = useState(false);
@ -321,7 +322,8 @@ export default function EmbeddingAuthoringDocumentEditPage() {
<ConfigureFieldsView <ConfigureFieldsView
configData={configuration!} configData={configuration!}
documentData={document.documentData} presignToken={token}
envelopeItem={document.envelopeItems[0]}
defaultValues={fields ?? undefined} defaultValues={fields ?? undefined}
onBack={canGoBack ? handleBackToConfig : undefined} onBack={canGoBack ? handleBackToConfig : undefined}
onSubmit={handleConfigureFieldsSubmit} onSubmit={handleConfigureFieldsSubmit}

View File

@ -75,6 +75,7 @@ export const loader = async ({ request, params }: Route.LoaderArgs) => {
})); }));
return { return {
token,
template: { template: {
...template, ...template,
fields, fields,
@ -86,7 +87,7 @@ export default function EmbeddingAuthoringTemplateEditPage() {
const { _ } = useLingui(); const { _ } = useLingui();
const { toast } = useToast(); const { toast } = useToast();
const { template } = useLoaderData<typeof loader>(); const { template, token } = useLoaderData<typeof loader>();
const [hasFinishedInit, setHasFinishedInit] = useState(false); const [hasFinishedInit, setHasFinishedInit] = useState(false);
@ -321,7 +322,8 @@ export default function EmbeddingAuthoringTemplateEditPage() {
<ConfigureFieldsView <ConfigureFieldsView
configData={configuration!} configData={configuration!}
documentData={template.templateDocumentData} presignToken={token}
envelopeItem={template.envelopeItems[0]}
defaultValues={fields ?? undefined} defaultValues={fields ?? undefined}
onBack={canGoBack ? handleBackToConfig : undefined} onBack={canGoBack ? handleBackToConfig : undefined}
onSubmit={handleConfigureFieldsSubmit} onSubmit={handleConfigureFieldsSubmit}

View File

@ -106,5 +106,5 @@
"vite-plugin-babel-macros": "^1.0.6", "vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^5.1.4" "vite-tsconfig-paths": "^5.1.4"
}, },
"version": "2.0.13" "version": "2.0.14"
} }

View File

@ -5,6 +5,7 @@ import { Hono } from 'hono';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session'; import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app'; import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import { getTeamById } from '@documenso/lib/server-only/team/get-team'; import { getTeamById } from '@documenso/lib/server-only/team/get-team';
import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server'; import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions'; import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions';
@ -16,6 +17,7 @@ import {
type TGetPresignedPostUrlResponse, type TGetPresignedPostUrlResponse,
ZGetEnvelopeItemFileDownloadRequestParamsSchema, ZGetEnvelopeItemFileDownloadRequestParamsSchema,
ZGetEnvelopeItemFileRequestParamsSchema, ZGetEnvelopeItemFileRequestParamsSchema,
ZGetEnvelopeItemFileRequestQuerySchema,
ZGetEnvelopeItemFileTokenDownloadRequestParamsSchema, ZGetEnvelopeItemFileTokenDownloadRequestParamsSchema,
ZGetEnvelopeItemFileTokenRequestParamsSchema, ZGetEnvelopeItemFileTokenRequestParamsSchema,
ZGetPresignedPostUrlRequestSchema, ZGetPresignedPostUrlRequestSchema,
@ -68,12 +70,24 @@ export const filesRoute = new Hono<HonoEnv>()
.get( .get(
'/envelope/:envelopeId/envelopeItem/:envelopeItemId', '/envelope/:envelopeId/envelopeItem/:envelopeItemId',
sValidator('param', ZGetEnvelopeItemFileRequestParamsSchema), sValidator('param', ZGetEnvelopeItemFileRequestParamsSchema),
sValidator('query', ZGetEnvelopeItemFileRequestQuerySchema),
async (c) => { async (c) => {
const { envelopeId, envelopeItemId } = c.req.valid('param'); const { envelopeId, envelopeItemId } = c.req.valid('param');
const { token } = c.req.query();
const session = await getOptionalSession(c); const session = await getOptionalSession(c);
if (!session.user) { let userId = session.user?.id;
if (token) {
const presignToken = await verifyEmbeddingPresignToken({
token,
}).catch(() => undefined);
userId = presignToken?.userId;
}
if (!userId) {
return c.json({ error: 'Unauthorized' }, 401); return c.json({ error: 'Unauthorized' }, 401);
} }
@ -104,7 +118,7 @@ export const filesRoute = new Hono<HonoEnv>()
} }
const team = await getTeamById({ const team = await getTeamById({
userId: session.user.id, userId: userId,
teamId: envelope.teamId, teamId: envelope.teamId,
}).catch((error) => { }).catch((error) => {
console.error(error); console.error(error);

View File

@ -36,6 +36,14 @@ export type TGetEnvelopeItemFileRequestParams = z.infer<
typeof ZGetEnvelopeItemFileRequestParamsSchema typeof ZGetEnvelopeItemFileRequestParamsSchema
>; >;
export const ZGetEnvelopeItemFileRequestQuerySchema = z.object({
token: z.string().optional(),
});
export type TGetEnvelopeItemFileRequestQuery = z.infer<
typeof ZGetEnvelopeItemFileRequestQuerySchema
>;
export const ZGetEnvelopeItemFileTokenRequestParamsSchema = z.object({ export const ZGetEnvelopeItemFileTokenRequestParamsSchema = z.object({
token: z.string().min(1), token: z.string().min(1),
envelopeItemId: z.string().min(1), envelopeItemId: z.string().min(1),

19
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "@documenso/root", "name": "@documenso/root",
"version": "2.0.13", "version": "2.0.14",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@documenso/root", "name": "@documenso/root",
"version": "2.0.13", "version": "2.0.14",
"workspaces": [ "workspaces": [
"apps/*", "apps/*",
"packages/*" "packages/*"
@ -19,6 +19,7 @@
"inngest-cli": "^0.29.1", "inngest-cli": "^0.29.1",
"luxon": "^3.5.0", "luxon": "^3.5.0",
"mupdf": "^1.0.0", "mupdf": "^1.0.0",
"pdf2json": "^4.0.0",
"react": "^18", "react": "^18",
"typescript": "5.6.2", "typescript": "5.6.2",
"zod": "^3.25.76" "zod": "^3.25.76"
@ -101,7 +102,7 @@
}, },
"apps/remix": { "apps/remix": {
"name": "@documenso/remix", "name": "@documenso/remix",
"version": "2.0.13", "version": "2.0.14",
"dependencies": { "dependencies": {
"@cantoo/pdf-lib": "^2.5.2", "@cantoo/pdf-lib": "^2.5.2",
"@documenso/api": "*", "@documenso/api": "*",
@ -27501,6 +27502,18 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/pdf2json": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/pdf2json/-/pdf2json-4.0.0.tgz",
"integrity": "sha512-WkezNsLK8sGpuFC7+PPP0DsXROwdoOxmXPBTtUWWkCwCi/Vi97MRC52Ly6FWIJjOKIywpm/L2oaUgSrmtU+7ZQ==",
"license": "Apache-2.0",
"bin": {
"pdf2json": "bin/pdf2json.js"
},
"engines": {
"node": ">=20.18.0"
}
},
"node_modules/pdfjs-dist": { "node_modules/pdfjs-dist": {
"version": "3.11.174", "version": "3.11.174",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-3.11.174.tgz", "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-3.11.174.tgz",

View File

@ -1,6 +1,6 @@
{ {
"private": true, "private": true,
"version": "2.0.13", "version": "2.0.14",
"scripts": { "scripts": {
"build": "turbo run build", "build": "turbo run build",
"dev": "turbo run dev --filter=@documenso/remix", "dev": "turbo run dev --filter=@documenso/remix",
@ -86,6 +86,7 @@
"inngest-cli": "^0.29.1", "inngest-cli": "^0.29.1",
"luxon": "^3.5.0", "luxon": "^3.5.0",
"mupdf": "^1.0.0", "mupdf": "^1.0.0",
"pdf2json": "^4.0.0",
"react": "^18", "react": "^18",
"typescript": "5.6.2", "typescript": "5.6.2",
"zod": "^3.25.76" "zod": "^3.25.76"

View File

@ -0,0 +1,129 @@
import { type Page, expect, test } from '@playwright/test';
import path from 'path';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
const SINGLE_PLACEHOLDER_PDF_PATH = path.join(
__dirname,
'../../../assets/project-proposal-single-recipient.pdf',
);
const MULTIPLE_PLACEHOLDER_PDF_PATH = path.join(
__dirname,
'../../../assets/project-proposal-multiple-fields-and-recipients.pdf',
);
const setupUserAndSignIn = async (page: Page) => {
const { user, team } = await seedUser();
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents`,
});
return { user, team };
};
const uploadPdfAndContinue = async (page: Page, pdfPath: string, continueClicks: number = 1) => {
const fileInput = page.locator('input[type="file"]').nth(1);
await fileInput.waitFor({ state: 'attached' });
await fileInput.setInputFiles(pdfPath);
await page.waitForTimeout(3000);
for (let i = 0; i < continueClicks; i++) {
await page.getByRole('button', { name: 'Continue' }).click();
}
};
test.describe('PDF Placeholders with single recipient', () => {
test('[AUTO_PLACING_FIELDS]: should automatically create recipients from PDF placeholders', async ({
page,
}) => {
await setupUserAndSignIn(page);
await uploadPdfAndContinue(page, SINGLE_PLACEHOLDER_PDF_PATH, 1);
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
await expect(page.getByPlaceholder('Email')).toHaveValue('recipient.1@documenso.com');
await expect(page.getByPlaceholder('Name')).toHaveValue('Recipient 1');
});
test('[AUTO_PLACING_FIELDS]: should automatically place fields from PDF placeholders', async ({
page,
}) => {
await setupUserAndSignIn(page);
await uploadPdfAndContinue(page, SINGLE_PLACEHOLDER_PDF_PATH, 2);
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await expect(page.locator('[data-field-type="SIGNATURE"]')).toBeVisible();
await expect(page.locator('[data-field-type="EMAIL"]')).toBeVisible();
await expect(page.locator('[data-field-type="NAME"]')).toBeVisible();
await expect(page.locator('[data-field-type="TEXT"]')).toBeVisible();
});
test('[AUTO_PLACING_FIELDS]: should automatically configure fields from PDF placeholders', async ({
page,
}) => {
await setupUserAndSignIn(page);
await uploadPdfAndContinue(page, SINGLE_PLACEHOLDER_PDF_PATH, 2);
await page.getByText('Text').nth(1).click();
await page.getByRole('button', { name: 'Advanced settings' }).click();
await expect(page.getByRole('heading', { name: 'Advanced settings' })).toBeVisible();
await expect(
page
.locator('div')
.filter({ hasText: /^Required field$/ })
.getByRole('switch'),
).toBeChecked();
await expect(page.getByRole('combobox')).toHaveText('Right');
});
});
test.describe('PDF Placeholders with multiple recipients', () => {
test('[AUTO_PLACING_FIELDS]: should automatically create recipients from PDF placeholders', async ({
page,
}) => {
await setupUserAndSignIn(page);
await uploadPdfAndContinue(page, MULTIPLE_PLACEHOLDER_PDF_PATH, 1);
await expect(page.getByTestId('signer-email-input').first()).toHaveValue(
'recipient.1@documenso.com',
);
await expect(page.getByLabel('Name').first()).toHaveValue('Recipient 1');
await expect(page.getByTestId('signer-email-input').nth(1)).toHaveValue(
'recipient.2@documenso.com',
);
await expect(page.getByLabel('Name').nth(1)).toHaveValue('Recipient 2');
await expect(page.getByTestId('signer-email-input').nth(2)).toHaveValue(
'recipient.3@documenso.com',
);
await expect(page.getByLabel('Name').nth(2)).toHaveValue('Recipient 3');
});
test('[AUTO_PLACING_FIELDS]: should automatically create fields from PDF placeholders', async ({
page,
}) => {
await setupUserAndSignIn(page);
await uploadPdfAndContinue(page, MULTIPLE_PLACEHOLDER_PDF_PATH, 2);
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await expect(page.locator('[data-field-type="SIGNATURE"]').first()).toBeVisible();
await expect(page.locator('[data-field-type="SIGNATURE"]').nth(1)).toBeVisible();
await expect(page.locator('[data-field-type="SIGNATURE"]').nth(2)).toBeVisible();
await expect(page.locator('[data-field-type="EMAIL"]').first()).toBeVisible();
await expect(page.locator('[data-field-type="EMAIL"]').nth(1)).toBeVisible();
await expect(page.locator('[data-field-type="NAME"]')).toBeVisible();
await expect(page.locator('[data-field-type="TEXT"]')).toBeVisible();
await expect(page.locator('[data-field-type="NUMBER"]')).toBeVisible();
});
});

Binary file not shown.

View File

@ -27,13 +27,13 @@ type HandleOAuthAuthorizeUrlOptions = {
/** /**
* Optional prompt to pass to the authorization endpoint. * Optional prompt to pass to the authorization endpoint.
*/ */
prompt?: 'login' | 'consent' | 'select_account'; prompt?: 'none' | 'login' | 'consent' | 'select_account';
}; };
const oauthCookieMaxAge = 60 * 10; // 10 minutes. const oauthCookieMaxAge = 60 * 10; // 10 minutes.
export const handleOAuthAuthorizeUrl = async (options: HandleOAuthAuthorizeUrlOptions) => { export const handleOAuthAuthorizeUrl = async (options: HandleOAuthAuthorizeUrlOptions) => {
const { c, clientOptions, redirectPath, prompt = 'login' } = options; const { c, clientOptions, redirectPath } = options;
if (!clientOptions.clientId || !clientOptions.clientSecret) { if (!clientOptions.clientId || !clientOptions.clientSecret) {
throw new AppError(AppErrorCode.NOT_SETUP); throw new AppError(AppErrorCode.NOT_SETUP);
@ -63,7 +63,11 @@ export const handleOAuthAuthorizeUrl = async (options: HandleOAuthAuthorizeUrlOp
); );
// Pass the prompt to the authorization endpoint. // Pass the prompt to the authorization endpoint.
if (process.env.NEXT_PRIVATE_OIDC_PROMPT !== '') {
const prompt = process.env.NEXT_PRIVATE_OIDC_PROMPT ?? 'login';
url.searchParams.append('prompt', prompt); url.searchParams.append('prompt', prompt);
}
setCookie(c, `${clientOptions.id}_oauth_state`, state, { setCookie(c, `${clientOptions.id}_oauth_state`, state, {
...sessionCookieOptions, ...sessionCookieOptions,

View File

@ -7,6 +7,7 @@ export const SUPPORTED_LANGUAGE_CODES = [
'es', 'es',
'it', 'it',
'pl', 'pl',
'pt-BR',
'ja', 'ja',
'ko', 'ko',
'zh', 'zh',
@ -64,6 +65,10 @@ export const SUPPORTED_LANGUAGES: Record<string, SupportedLanguage> = {
short: 'pl', short: 'pl',
full: 'Polish', full: 'Polish',
}, },
'pt-BR': {
short: 'pt-BR',
full: 'Portuguese (Brazil)',
},
ja: { ja: {
short: 'ja', short: 'ja',
full: 'Japanese', full: 'Japanese',

View File

@ -25,7 +25,6 @@ import { signPdf } from '@documenso/signing';
import { AppError, AppErrorCode } from '../../../errors/app-error'; import { AppError, AppErrorCode } from '../../../errors/app-error';
import { sendCompletedEmail } from '../../../server-only/document/send-completed-email'; import { sendCompletedEmail } from '../../../server-only/document/send-completed-email';
import PostHogServerClient from '../../../server-only/feature-flags/get-post-hog-server-client';
import { getAuditLogsPdf } from '../../../server-only/htmltopdf/get-audit-logs-pdf'; import { getAuditLogsPdf } from '../../../server-only/htmltopdf/get-audit-logs-pdf';
import { getCertificatePdf } from '../../../server-only/htmltopdf/get-certificate-pdf'; import { getCertificatePdf } from '../../../server-only/htmltopdf/get-certificate-pdf';
import { addRejectionStampToPdf } from '../../../server-only/pdf/add-rejection-stamp-to-pdf'; import { addRejectionStampToPdf } from '../../../server-only/pdf/add-rejection-stamp-to-pdf';
@ -62,6 +61,7 @@ export const run = async ({
}) => { }) => {
const { documentId, sendEmail = true, isResealing = false, requestMetadata } = payload; const { documentId, sendEmail = true, isResealing = false, requestMetadata } = payload;
const { envelopeId, envelopeStatus, isRejected } = await io.runTask('seal-document', async () => {
const envelope = await prisma.envelope.findFirstOrThrow({ const envelope = await prisma.envelope.findFirstOrThrow({
where: { where: {
type: EnvelopeType.DOCUMENT, type: EnvelopeType.DOCUMENT,
@ -102,24 +102,7 @@ export const run = async ({
}); });
} }
// Seems silly but we need to do this in case the job is re-ran let envelopeItems = envelope.envelopeItems;
// after it has already run through the update task further below.
// eslint-disable-next-line @typescript-eslint/require-await
const documentStatus = await io.runTask('get-document-status', async () => {
return envelope.status;
});
// This is the same case as above.
let envelopeItems = await io.runTask(
'get-document-data-id',
// eslint-disable-next-line @typescript-eslint/require-await
async () => {
// eslint-disable-next-line unused-imports/no-unused-vars
return envelope.envelopeItems.map(({ field, ...rest }) => ({
...rest,
}));
},
);
if (envelopeItems.length < 1) { if (envelopeItems.length < 1) {
throw new Error(`Document ${envelope.id} has no envelope items`); throw new Error(`Document ${envelope.id} has no envelope items`);
@ -189,44 +172,9 @@ export const run = async ({
settings, settings,
}); });
// !: The commented out code is our desired implementation but we're seemingly
// !: running into issues with inngest parallelism in production.
// !: Until this is resolved we will do this sequentially which is slower but
// !: will actually work.
// const decoratePromises: Array<Promise<{ oldDocumentDataId: string; newDocumentDataId: string }>> =
// [];
// for (const envelopeItem of envelopeItems) {
// const task = io.runTask(`decorate-${envelopeItem.id}`, async () => {
// const envelopeItemFields = envelope.envelopeItems.find(
// (item) => item.id === envelopeItem.id,
// )?.field;
// if (!envelopeItemFields) {
// throw new Error(`Envelope item fields not found for envelope item ${envelopeItem.id}`);
// }
// return decorateAndSignPdf({
// envelope,
// envelopeItem,
// envelopeItemFields,
// isRejected,
// rejectionReason,
// certificateData,
// auditLogData,
// });
// });
// decoratePromises.push(task);
// }
// const newDocumentData = await Promise.all(decoratePromises);
// TODO: Remove once parallelization is working
const newDocumentData: Array<{ oldDocumentDataId: string; newDocumentDataId: string }> = []; const newDocumentData: Array<{ oldDocumentDataId: string; newDocumentDataId: string }> = [];
for (const envelopeItem of envelopeItems) { for (const envelopeItem of envelopeItems) {
const result = await io.runTask(`decorate-${envelopeItem.id}`, async () => {
const envelopeItemFields = envelope.envelopeItems.find( const envelopeItemFields = envelope.envelopeItems.find(
(item) => item.id === envelopeItem.id, (item) => item.id === envelopeItem.id,
)?.field; )?.field;
@ -235,7 +183,7 @@ export const run = async ({
throw new Error(`Envelope item fields not found for envelope item ${envelopeItem.id}`); throw new Error(`Envelope item fields not found for envelope item ${envelopeItem.id}`);
} }
return decorateAndSignPdf({ const result = await decorateAndSignPdf({
envelope, envelope,
envelopeItem, envelopeItem,
envelopeItemFields, envelopeItemFields,
@ -244,25 +192,10 @@ export const run = async ({
certificateData, certificateData,
auditLogData, auditLogData,
}); });
});
newDocumentData.push(result); newDocumentData.push(result);
} }
const postHog = PostHogServerClient();
if (postHog) {
postHog.capture({
distinctId: nanoid(),
event: 'App: Document Sealed',
properties: {
documentId: envelope.id,
isRejected,
},
});
}
await io.runTask('update-document', async () => {
await prisma.$transaction(async (tx) => { await prisma.$transaction(async (tx) => {
for (const { oldDocumentDataId, newDocumentDataId } of newDocumentData) { for (const { oldDocumentDataId, newDocumentDataId } of newDocumentData) {
const newData = await tx.documentData.findFirstOrThrow({ const newData = await tx.documentData.findFirstOrThrow({
@ -304,18 +237,24 @@ export const run = async ({
}), }),
}); });
}); });
return {
envelopeId: envelope.id,
envelopeStatus: envelope.status,
isRejected,
};
}); });
await io.runTask('send-completed-email', async () => { await io.runTask('send-completed-email', async () => {
let shouldSendCompletedEmail = sendEmail && !isResealing && !isRejected; let shouldSendCompletedEmail = sendEmail && !isResealing && !isRejected;
if (isResealing && !isDocumentCompleted(envelope.status)) { if (isResealing && !isDocumentCompleted(envelopeStatus)) {
shouldSendCompletedEmail = sendEmail; shouldSendCompletedEmail = sendEmail;
} }
if (shouldSendCompletedEmail) { if (shouldSendCompletedEmail) {
await sendCompletedEmail({ await sendCompletedEmail({
id: { type: 'envelopeId', id: envelope.id }, id: { type: 'envelopeId', id: envelopeId },
requestMetadata, requestMetadata,
}); });
} }
@ -323,7 +262,7 @@ export const run = async ({
const updatedEnvelope = await prisma.envelope.findFirstOrThrow({ const updatedEnvelope = await prisma.envelope.findFirstOrThrow({
where: { where: {
id: envelope.id, id: envelopeId,
}, },
include: { include: {
documentMeta: true, documentMeta: true,

View File

@ -103,6 +103,7 @@ export const getDocumentAndSenderByToken = async ({
select: { select: {
name: true, name: true,
teamEmail: true, teamEmail: true,
url: true,
teamGlobalSettings: { teamGlobalSettings: {
select: { select: {
brandingEnabled: true, brandingEnabled: true,

View File

@ -10,6 +10,11 @@ import {
} from '@prisma/client'; } from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import {
extractPlaceholdersFromPDF,
insertFieldsFromPlaceholdersInPDF,
removePlaceholdersFromPDF,
} from '@documenso/lib/server-only/pdf/auto-place-fields';
import { normalizePdf as makeNormalizedPdf } from '@documenso/lib/server-only/pdf/normalize-pdf'; import { normalizePdf as makeNormalizedPdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
@ -34,6 +39,7 @@ import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../universal/upload/put-file.server'; import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
import { extractDerivedDocumentMeta } from '../../utils/document'; import { extractDerivedDocumentMeta } from '../../utils/document';
import { createDocumentAuthOptions, createRecipientAuthOptions } from '../../utils/document-auth'; import { createDocumentAuthOptions, createRecipientAuthOptions } from '../../utils/document-auth';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { buildTeamWhereQuery } from '../../utils/teams'; import { buildTeamWhereQuery } from '../../utils/teams';
import { incrementDocumentId, incrementTemplateId } from '../envelope/increment-id'; import { incrementDocumentId, incrementTemplateId } from '../envelope/increment-id';
import { getTeamSettings } from '../team/get-team-settings'; import { getTeamSettings } from '../team/get-team-settings';
@ -256,7 +262,7 @@ export const createEnvelope = async ({
? await incrementDocumentId().then((v) => v.formattedDocumentId) ? await incrementDocumentId().then((v) => v.formattedDocumentId)
: await incrementTemplateId().then((v) => v.formattedTemplateId); : await incrementTemplateId().then((v) => v.formattedTemplateId);
return await prisma.$transaction(async (tx) => { const createdEnvelope = await prisma.$transaction(async (tx) => {
const envelope = await tx.envelope.create({ const envelope = await tx.envelope.create({
data: { data: {
id: prefixedId('envelope'), id: prefixedId('envelope'),
@ -376,8 +382,12 @@ export const createEnvelope = async ({
recipients: true, recipients: true,
fields: true, fields: true,
folder: true, folder: true,
envelopeItems: true,
envelopeAttachments: true, envelopeAttachments: true,
envelopeItems: {
include: {
documentData: true,
},
},
}, },
}); });
@ -413,4 +423,74 @@ export const createEnvelope = async ({
return createdEnvelope; return createdEnvelope;
}); });
for (const envelopeItem of createdEnvelope.envelopeItems) {
const buffer = await getFileServerSide(envelopeItem.documentData);
const pdfToProcess = Buffer.from(buffer);
const envelopeOptions: EnvelopeIdOptions = {
type: 'envelopeId',
id: createdEnvelope.id,
};
const placeholders = await extractPlaceholdersFromPDF(pdfToProcess);
if (placeholders.length > 0) {
const pdfWithoutPlaceholders = await removePlaceholdersFromPDF(pdfToProcess);
await insertFieldsFromPlaceholdersInPDF(
pdfWithoutPlaceholders,
userId,
teamId,
envelopeOptions,
requestMetadata,
envelopeItem.id,
createdEnvelope.recipients,
);
const titleToUse = envelopeItem.title || title;
const fileName = titleToUse.endsWith('.pdf') ? titleToUse : `${titleToUse}.pdf`;
const newDocumentData = await putPdfFileServerSide({
name: fileName,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(pdfWithoutPlaceholders),
});
await prisma.envelopeItem.update({
where: {
id: envelopeItem.id,
},
data: {
documentDataId: newDocumentData.id,
},
});
}
}
const finalEnvelope = await prisma.envelope.findFirst({
where: {
id: createdEnvelope.id,
},
include: {
documentMeta: true,
recipients: true,
fields: true,
folder: true,
envelopeAttachments: true,
envelopeItems: {
include: {
documentData: true,
},
},
},
});
if (!finalEnvelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope not found',
});
}
return finalEnvelope;
}; };

View File

@ -0,0 +1,347 @@
import { PDFDocument, rgb } from '@cantoo/pdf-lib';
import type { Recipient } from '@prisma/client';
import PDFParser from 'pdf2json';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { createEnvelopeFields } from '@documenso/lib/server-only/field/create-envelope-fields';
import { type TFieldAndMeta, ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import type { EnvelopeIdOptions } from '@documenso/lib/utils/envelope';
import { prisma } from '@documenso/prisma';
import { getPageSize } from './get-page-size';
import {
determineRecipientsForPlaceholders,
extractRecipientPlaceholder,
findRecipientByPlaceholder,
parseFieldMetaFromPlaceholder,
parseFieldTypeFromPlaceholder,
} from './helpers';
const PLACEHOLDER_REGEX = /{{([^}]+)}}/g;
const DEFAULT_FIELD_HEIGHT_PERCENT = 2;
const WIDTH_ADJUSTMENT_FACTOR = 0.1;
const MIN_HEIGHT_THRESHOLD = 0.01;
type TextPosition = {
text: string;
x: number;
y: number;
w: number;
};
type CharIndexMapping = {
textPositionIndex: number;
};
type PlaceholderInfo = {
placeholder: string;
recipient: string;
fieldAndMeta: TFieldAndMeta;
page: number;
x: number;
y: number;
width: number;
height: number;
pageWidth: number;
pageHeight: number;
};
type FieldToCreate = TFieldAndMeta & {
envelopeItemId?: string;
recipientId: number;
page: number;
positionX: number;
positionY: number;
width: number;
height: number;
};
export const extractPlaceholdersFromPDF = async (pdf: Buffer): Promise<PlaceholderInfo[]> => {
return new Promise((resolve, reject) => {
const parser = new PDFParser(null, true);
parser.on('pdfParser_dataError', (errData) => {
reject(errData);
});
parser.on('pdfParser_dataReady', (pdfData) => {
const placeholders: PlaceholderInfo[] = [];
pdfData.Pages.forEach((page, pageIndex) => {
/*
pdf2json returns the PDF page content as an array of characters.
We need to concatenate the characters to get the full text.
We also need to get the position of the text so we can place the placeholders in the correct position.
Page dimensions from PDF2JSON are in "page units" (relative coordinates)
*/
let pageText = '';
const textPositions: TextPosition[] = [];
const charIndexMappings: CharIndexMapping[] = [];
page.Texts.forEach((text) => {
/*
R is an array of objects containing each character, its position and styling information.
The decodedText stores the characters, without any other information.
textPositions stores each character and its position on the page.
*/
const decodedText = text.R.map((run) => decodeURIComponent(run.T)).join('');
/*
For each character in the decodedText, we store its position in the textPositions array.
This allows us to quickly find the position of a character in the textPositions array by its index.
*/
for (let i = 0; i < decodedText.length; i++) {
charIndexMappings.push({
textPositionIndex: textPositions.length,
});
}
pageText += decodedText;
textPositions.push({
text: decodedText,
x: text.x,
y: text.y,
w: text.w || 0,
});
});
const placeholderMatches = pageText.matchAll(PLACEHOLDER_REGEX);
/*
A placeholder match has the following format:
[
'{{fieldType,recipient,fieldMeta}}',
'fieldType,recipient,fieldMeta',
'index: <number>',
'input: <pdf-text>'
]
*/
for (const placeholderMatch of placeholderMatches) {
const placeholder = placeholderMatch[0];
const placeholderData = placeholderMatch[1].split(',').map((property) => property.trim());
const [fieldTypeString, recipient, ...fieldMetaData] = placeholderData;
const rawFieldMeta = Object.fromEntries(
fieldMetaData.map((property) => property.split('=')),
);
const fieldType = parseFieldTypeFromPlaceholder(fieldTypeString);
const parsedFieldMeta = parseFieldMetaFromPlaceholder(rawFieldMeta, fieldType);
const fieldAndMeta: TFieldAndMeta = ZFieldAndMetaSchema.parse({
type: fieldType,
fieldMeta: parsedFieldMeta,
});
/*
Find the position of where the placeholder starts and ends in the text.
Then find the position of the characters in the textPositions array.
This allows us to quickly find the position of a character in the textPositions array by its index.
*/
const placeholderEndCharIndex = placeholderMatch.index + placeholder.length;
/*
Get the index of the placeholder's first and last character in the textPositions array.
Used to retrieve the character information from the textPositions array.
Example:
startTextPosIndex - 1
endTextPosIndex - 40
*/
const startTextPosIndex = charIndexMappings[placeholderMatch.index].textPositionIndex;
const endTextPosIndex = charIndexMappings[placeholderEndCharIndex - 1].textPositionIndex;
/*
Get the placeholder's first and last character information from the textPositions array.
Example:
placeholderStart = { text: '{', x: 100, y: 100, w: 100 }
placeholderEnd = { text: '}', x: 200, y: 100, w: 100 }
*/
const placeholderStart = textPositions[startTextPosIndex];
const placeholderEnd = textPositions[endTextPosIndex];
const width =
placeholderEnd.x + placeholderEnd.w * WIDTH_ADJUSTMENT_FACTOR - placeholderStart.x;
placeholders.push({
placeholder,
recipient,
fieldAndMeta,
page: pageIndex + 1,
x: placeholderStart.x,
y: placeholderStart.y,
width,
height: 1,
pageWidth: page.Width,
pageHeight: page.Height,
});
}
});
resolve(placeholders);
});
parser.parseBuffer(pdf);
});
};
export const removePlaceholdersFromPDF = async (pdf: Buffer): Promise<Buffer> => {
const placeholders = await extractPlaceholdersFromPDF(pdf);
const pdfDoc = await PDFDocument.load(new Uint8Array(pdf));
const pages = pdfDoc.getPages();
for (const placeholder of placeholders) {
const pageIndex = placeholder.page - 1;
const page = pages[pageIndex];
const { width: pdfLibPageWidth, height: pdfLibPageHeight } = getPageSize(page);
/*
Convert PDF2JSON coordinates to pdf-lib coordinates:
PDF2JSON uses relative "page units":
- x, y, width, height are in page units
- Page dimensions (Width, Height) are also in page units
pdf-lib uses absolute points (1 point = 1/72 inch):
- Need to convert from page units to points
- Y-axis in pdf-lib is bottom-up (origin at bottom-left)
- Y-axis in PDF2JSON is top-down (origin at top-left)
*/
const xPoints = (placeholder.x / placeholder.pageWidth) * pdfLibPageWidth;
const yPoints = pdfLibPageHeight - (placeholder.y / placeholder.pageHeight) * pdfLibPageHeight;
const widthPoints = (placeholder.width / placeholder.pageWidth) * pdfLibPageWidth;
const heightPoints = (placeholder.height / placeholder.pageHeight) * pdfLibPageHeight;
page.drawRectangle({
x: xPoints,
y: yPoints - heightPoints, // Adjust for height since y is at baseline
width: widthPoints,
height: heightPoints,
color: rgb(1, 1, 1),
borderColor: rgb(1, 1, 1),
borderWidth: 2,
});
}
const modifiedPdfBytes = await pdfDoc.save();
return Buffer.from(modifiedPdfBytes);
};
export const insertFieldsFromPlaceholdersInPDF = async (
pdf: Buffer,
userId: number,
teamId: number,
envelopeId: EnvelopeIdOptions,
requestMetadata: ApiRequestMetadata,
envelopeItemId?: string,
recipients?: Pick<Recipient, 'id' | 'email'>[],
): Promise<Buffer> => {
const placeholders = await extractPlaceholdersFromPDF(pdf);
if (placeholders.length === 0) {
return pdf;
}
/*
A structure that maps the recipient index to the recipient name.
Example: 1 => 'Recipient 1'
*/
const recipientPlaceholders = new Map<number, string>();
for (const placeholder of placeholders) {
const { name, recipientIndex } = extractRecipientPlaceholder(placeholder.recipient);
recipientPlaceholders.set(recipientIndex, name);
}
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: envelopeId,
userId,
teamId,
type: null,
});
const envelope = await prisma.envelope.findFirst({
where: envelopeWhereInput,
select: {
id: true,
type: true,
secondaryId: true,
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope not found',
});
}
const createdRecipients = await determineRecipientsForPlaceholders(
recipients,
recipientPlaceholders,
envelope,
userId,
teamId,
requestMetadata,
);
const fieldsToCreate: FieldToCreate[] = [];
for (const placeholder of placeholders) {
/*
Convert PDF2JSON coordinates to percentage-based coordinates (0-100)
The UI expects positionX and positionY as percentages, not absolute points
PDF2JSON uses relative coordinates: x/pageWidth and y/pageHeight give us the percentage
*/
const xPercent = (placeholder.x / placeholder.pageWidth) * 100;
const yPercent = (placeholder.y / placeholder.pageHeight) * 100;
const widthPercent = (placeholder.width / placeholder.pageWidth) * 100;
const heightPercent = (placeholder.height / placeholder.pageHeight) * 100;
const recipient = findRecipientByPlaceholder(
placeholder.recipient,
placeholder.placeholder,
recipients,
createdRecipients,
);
// Default height percentage if too small (use 2% as a reasonable default)
const finalHeightPercent =
heightPercent > MIN_HEIGHT_THRESHOLD ? heightPercent : DEFAULT_FIELD_HEIGHT_PERCENT;
fieldsToCreate.push({
...placeholder.fieldAndMeta,
envelopeItemId,
recipientId: recipient.id,
page: placeholder.page,
positionX: xPercent,
positionY: yPercent,
width: widthPercent,
height: finalHeightPercent,
});
}
await createEnvelopeFields({
userId,
teamId,
id: envelopeId,
fields: fieldsToCreate,
requestMetadata,
});
return pdf;
};

View File

@ -0,0 +1,266 @@
import { FieldType } from '@prisma/client';
import { type Envelope, EnvelopeType, RecipientRole } from '@prisma/client';
import type { Recipient } from '@prisma/client';
import { match } from 'ts-pattern';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { createEnvelopeRecipients } from '@documenso/lib/server-only/recipient/create-envelope-recipients';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import type { EnvelopeIdOptions } from '@documenso/lib/utils/envelope';
import { prisma } from '@documenso/prisma';
type RecipientPlaceholderInfo = {
email: string;
name: string;
recipientIndex: number;
};
/*
Parse field type string to FieldType enum.
Normalizes the input (uppercase, trim) and validates it's a valid field type.
This ensures we handle case variations and whitespace, and provides clear error messages.
*/
export const parseFieldTypeFromPlaceholder = (fieldTypeString: string): FieldType => {
const normalizedType = fieldTypeString.toUpperCase().trim();
return match(normalizedType)
.with('SIGNATURE', () => FieldType.SIGNATURE)
.with('FREE_SIGNATURE', () => FieldType.FREE_SIGNATURE)
.with('INITIALS', () => FieldType.INITIALS)
.with('NAME', () => FieldType.NAME)
.with('EMAIL', () => FieldType.EMAIL)
.with('DATE', () => FieldType.DATE)
.with('TEXT', () => FieldType.TEXT)
.with('NUMBER', () => FieldType.NUMBER)
.with('RADIO', () => FieldType.RADIO)
.with('CHECKBOX', () => FieldType.CHECKBOX)
.with('DROPDOWN', () => FieldType.DROPDOWN)
.otherwise(() => {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Invalid field type: ${fieldTypeString}`,
});
});
};
/*
Transform raw field metadata from placeholder format to schema format.
Users should provide properly capitalized property names (e.g., readOnly, fontSize, textAlign).
Converts string values to proper types (booleans, numbers).
*/
export const parseFieldMetaFromPlaceholder = (
rawFieldMeta: Record<string, string>,
fieldType: FieldType,
): Record<string, unknown> | undefined => {
if (fieldType === FieldType.SIGNATURE || fieldType === FieldType.FREE_SIGNATURE) {
return;
}
if (Object.keys(rawFieldMeta).length === 0) {
return;
}
const fieldTypeString = String(fieldType).toLowerCase();
const parsedFieldMeta: Record<string, boolean | number | string> = {
type: fieldTypeString,
};
/*
rawFieldMeta is an object with string keys and string values.
It contains string values because the PDF parser returns the values as strings.
E.g. { 'required': 'true', 'fontSize': '12', 'maxValue': '100', 'minValue': '0', 'characterLimit': '100' }
*/
const rawFieldMetaEntries = Object.entries(rawFieldMeta);
for (const [property, value] of rawFieldMetaEntries) {
if (property === 'readOnly' || property === 'required') {
parsedFieldMeta[property] = value === 'true';
} else if (
property === 'fontSize' ||
property === 'maxValue' ||
property === 'minValue' ||
property === 'characterLimit'
) {
const numValue = Number(value);
if (!Number.isNaN(numValue)) {
parsedFieldMeta[property] = numValue;
}
} else {
parsedFieldMeta[property] = value;
}
}
return parsedFieldMeta;
};
export const extractRecipientPlaceholder = (placeholder: string): RecipientPlaceholderInfo => {
const indexMatch = placeholder.match(/^r(\d+)$/i);
if (!indexMatch) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Invalid recipient placeholder format: ${placeholder}. Expected format: r1, r2, r3, etc.`,
});
}
const recipientIndex = Number(indexMatch[1]);
return {
email: `recipient.${recipientIndex}@documenso.com`,
name: `Recipient ${recipientIndex}`,
recipientIndex,
};
};
/*
Finds a recipient based on a placeholder reference.
If recipients array is provided, uses index-based matching (r1 -> recipients[0], etc.).
Otherwise, uses email-based matching from createdRecipients.
*/
export const findRecipientByPlaceholder = (
recipientPlaceholder: string,
placeholder: string,
recipients: Pick<Recipient, 'id' | 'email'>[] | undefined,
createdRecipients: Pick<Recipient, 'id' | 'email'>[],
): Pick<Recipient, 'id' | 'email'> => {
if (recipients && recipients.length > 0) {
/*
Map placeholder by index: r1 -> recipients[0], r2 -> recipients[1], etc.
recipientIndex is 1-based, so we subtract 1 to get the array index.
*/
const { recipientIndex } = extractRecipientPlaceholder(recipientPlaceholder);
const recipientArrayIndex = recipientIndex - 1;
if (recipientArrayIndex < 0 || recipientArrayIndex >= recipients.length) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Recipient placeholder ${recipientPlaceholder} (index ${recipientIndex}) is out of range. Provided ${recipients.length} recipient(s).`,
});
}
return recipients[recipientArrayIndex];
}
/*
Use email-based matching for placeholder recipients.
*/
const { email } = extractRecipientPlaceholder(recipientPlaceholder);
const recipient = createdRecipients.find((r) => r.email === email);
if (!recipient) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Could not find recipient ID for placeholder: ${placeholder}`,
});
}
return recipient;
};
/*
Determines the recipients to use for field creation.
If recipients are provided, uses them directly.
Otherwise, creates recipients from placeholders.
*/
export const determineRecipientsForPlaceholders = async (
recipients: Pick<Recipient, 'id' | 'email'>[] | undefined,
recipientPlaceholders: Map<number, string>,
envelope: Pick<Envelope, 'id' | 'type' | 'secondaryId'>,
userId: number,
teamId: number,
requestMetadata: ApiRequestMetadata,
): Promise<Pick<Recipient, 'id' | 'email'>[]> => {
if (recipients && recipients.length > 0) {
return recipients;
}
return createRecipientsFromPlaceholders(
recipientPlaceholders,
envelope,
userId,
teamId,
requestMetadata,
);
};
export const createRecipientsFromPlaceholders = async (
recipientPlaceholders: Map<number, string>,
envelope: Pick<Envelope, 'id' | 'type' | 'secondaryId'>,
userId: number,
teamId: number,
requestMetadata: ApiRequestMetadata,
): Promise<Pick<Recipient, 'id' | 'email'>[]> => {
const recipientsToCreate = Array.from(
recipientPlaceholders.entries(),
([recipientIndex, name]) => {
return {
email: `recipient.${recipientIndex}@documenso.com`,
name,
role: RecipientRole.SIGNER,
signingOrder: recipientIndex,
};
},
);
const existingRecipients = await prisma.recipient.findMany({
where: {
envelopeId: envelope.id,
},
select: {
id: true,
email: true,
},
});
const existingEmails = new Set(existingRecipients.map((r) => r.email));
const recipientsToCreateFiltered = recipientsToCreate.filter(
(recipient) => !existingEmails.has(recipient.email),
);
if (recipientsToCreateFiltered.length === 0) {
return existingRecipients;
}
const newRecipients = await match(envelope.type)
.with(EnvelopeType.DOCUMENT, async () => {
const envelopeId: EnvelopeIdOptions = {
type: 'envelopeId',
id: envelope.id,
};
const { recipients } = await createEnvelopeRecipients({
userId,
teamId,
id: envelopeId,
recipients: recipientsToCreateFiltered,
requestMetadata,
});
return recipients;
})
.with(EnvelopeType.TEMPLATE, async () => {
const templateId = mapSecondaryIdToTemplateId(envelope.secondaryId ?? '');
const envelopeId: EnvelopeIdOptions = {
type: 'templateId',
id: templateId,
};
const { recipients } = await createEnvelopeRecipients({
userId,
teamId,
id: envelopeId,
recipients: recipientsToCreateFiltered,
requestMetadata,
});
return recipients;
})
.otherwise(() => {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Invalid envelope type: ${envelope.type}`,
});
});
return [...existingRecipients, ...newRecipients];
};

View File

@ -23,5 +23,7 @@ export const normalizePdf = async (pdf: Buffer) => {
await flattenForm(pdfDoc); await flattenForm(pdfDoc);
flattenAnnotations(pdfDoc); flattenAnnotations(pdfDoc);
return Buffer.from(await pdfDoc.save()); const normalizedPdfBytes = await pdfDoc.save();
return Buffer.from(normalizedPdfBytes);
}; };

View File

@ -8,7 +8,7 @@ msgstr ""
"Language: pl\n" "Language: pl\n"
"Project-Id-Version: documenso-app\n" "Project-Id-Version: documenso-app\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2025-11-17 02:33\n" "PO-Revision-Date: 2025-11-20 02:32\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: Polish\n" "Language-Team: Polish\n"
"Plural-Forms: nplurals=4; plural=(n==1 ? 0 : (n%10>=2 && n%10<=4) && (n%100<12 || n%100>14) ? 1 : n!=1 && (n%10>=0 && n%10<=1) || (n%10>=5 && n%10<=9) || (n%100>=12 && n%100<=14) ? 2 : 3);\n" "Plural-Forms: nplurals=4; plural=(n==1 ? 0 : (n%10>=2 && n%10<=4) && (n%100<12 || n%100>14) ? 1 : n!=1 && (n%10>=0 && n%10<=1) || (n%10>=5 && n%10<=9) || (n%100>=12 && n%100<=14) ? 2 : 3);\n"
@ -179,7 +179,7 @@ msgstr "Sprawdź i {recipientActionVerb} dokument utworzony przez zespół {0}"
#: apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx #: apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx
#: apps/remix/app/components/general/document/document-upload-button-legacy.tsx #: apps/remix/app/components/general/document/document-upload-button-legacy.tsx
msgid "{0} of {1} documents remaining this month." msgid "{0} of {1} documents remaining this month."
msgstr "{0} z {1} dokumentów pozostałych w tym miesiącu." msgstr "Pozostało {0} z {1} dokumentów w tym miesiącu."
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length #. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
#. placeholder {1}: table.getFilteredRowModel().rows.length #. placeholder {1}: table.getFilteredRowModel().rows.length

File diff suppressed because it is too large Load Diff

View File

@ -8,15 +8,17 @@ export type EnvelopeItemPdfUrlOptions =
envelopeItem: Pick<EnvelopeItem, 'id' | 'envelopeId'>; envelopeItem: Pick<EnvelopeItem, 'id' | 'envelopeId'>;
token: string | undefined; token: string | undefined;
version: 'original' | 'signed'; version: 'original' | 'signed';
presignToken?: undefined;
} }
| { | {
type: 'view'; type: 'view';
envelopeItem: Pick<EnvelopeItem, 'id' | 'envelopeId'>; envelopeItem: Pick<EnvelopeItem, 'id' | 'envelopeId'>;
token: string | undefined; token: string | undefined;
presignToken?: string | undefined;
}; };
export const getEnvelopeItemPdfUrl = (options: EnvelopeItemPdfUrlOptions) => { export const getEnvelopeItemPdfUrl = (options: EnvelopeItemPdfUrlOptions) => {
const { envelopeItem, token, type } = options; const { envelopeItem, token, type, presignToken } = options;
const { id, envelopeId } = envelopeItem; const { id, envelopeId } = envelopeItem;
@ -24,11 +26,11 @@ export const getEnvelopeItemPdfUrl = (options: EnvelopeItemPdfUrlOptions) => {
const version = options.version; const version = options.version;
return token return token
? `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/token/${token}/envelopeItem/${id}/download/${version}` ? `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/token/${token}/envelopeItem/${id}/download/${version}${presignToken ? `?presignToken=${presignToken}` : ''}`
: `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/envelope/${envelopeId}/envelopeItem/${id}/download/${version}`; : `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/envelope/${envelopeId}/envelopeItem/${id}/download/${version}`;
} }
return token return token
? `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/token/${token}/envelopeItem/${id}` ? `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/token/${token}/envelopeItem/${id}${presignToken ? `?presignToken=${presignToken}` : ''}`
: `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/envelope/${envelopeId}/envelopeItem/${id}`; : `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/envelope/${envelopeId}/envelopeItem/${id}`;
}; };

View File

@ -25,6 +25,7 @@ import { redistributeEnvelopeRoute } from './redistribute-envelope';
import { setEnvelopeFieldsRoute } from './set-envelope-fields'; import { setEnvelopeFieldsRoute } from './set-envelope-fields';
import { setEnvelopeRecipientsRoute } from './set-envelope-recipients'; import { setEnvelopeRecipientsRoute } from './set-envelope-recipients';
import { signEnvelopeFieldRoute } from './sign-envelope-field'; import { signEnvelopeFieldRoute } from './sign-envelope-field';
import { signingStatusEnvelopeRoute } from './signing-status-envelope';
import { updateEnvelopeRoute } from './update-envelope'; import { updateEnvelopeRoute } from './update-envelope';
import { updateEnvelopeItemsRoute } from './update-envelope-items'; import { updateEnvelopeItemsRoute } from './update-envelope-items';
import { useEnvelopeRoute } from './use-envelope'; import { useEnvelopeRoute } from './use-envelope';
@ -72,4 +73,5 @@ export const envelopeRouter = router({
duplicate: duplicateEnvelopeRoute, duplicate: duplicateEnvelopeRoute,
distribute: distributeEnvelopeRoute, distribute: distributeEnvelopeRoute,
redistribute: redistributeEnvelopeRoute, redistribute: redistributeEnvelopeRoute,
signingStatus: signingStatusEnvelopeRoute,
}); });

View File

@ -0,0 +1,82 @@
import { DocumentStatus, EnvelopeType, RecipientRole, SigningStatus } from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { maybeAuthenticatedProcedure } from '../trpc';
import {
ZSigningStatusEnvelopeRequestSchema,
ZSigningStatusEnvelopeResponseSchema,
} from './signing-status-envelope.types';
// Internal route - not intended for public API usage
export const signingStatusEnvelopeRoute = maybeAuthenticatedProcedure
.input(ZSigningStatusEnvelopeRequestSchema)
.output(ZSigningStatusEnvelopeResponseSchema)
.query(async ({ input, ctx }) => {
const { token } = input;
ctx.logger.info({
input: {
token,
},
});
const envelope = await prisma.envelope.findFirst({
where: {
type: EnvelopeType.DOCUMENT,
recipients: {
some: {
token,
},
},
},
include: {
recipients: {
select: {
id: true,
name: true,
email: true,
signingStatus: true,
role: true,
},
},
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope not found',
});
}
// Check if envelope is rejected
if (envelope.status === DocumentStatus.REJECTED) {
return {
status: 'REJECTED',
};
}
if (envelope.status === DocumentStatus.COMPLETED) {
return {
status: 'COMPLETED',
};
}
const isComplete =
envelope.recipients.some((recipient) => recipient.signingStatus === SigningStatus.REJECTED) ||
envelope.recipients.every(
(recipient) =>
recipient.role === RecipientRole.CC || recipient.signingStatus === SigningStatus.SIGNED,
);
if (isComplete) {
return {
status: 'PROCESSING',
};
}
return {
status: 'PENDING',
};
});

View File

@ -0,0 +1,14 @@
import { z } from 'zod';
export const EnvelopeSigningStatus = z.enum(['PENDING', 'PROCESSING', 'COMPLETED', 'REJECTED']);
export const ZSigningStatusEnvelopeRequestSchema = z.object({
token: z.string().describe('The recipient token to check the signing status for'),
});
export const ZSigningStatusEnvelopeResponseSchema = z.object({
status: EnvelopeSigningStatus.describe('The current signing status of the envelope'),
});
export type TSigningStatusEnvelopeRequest = z.infer<typeof ZSigningStatusEnvelopeRequestSchema>;
export type TSigningStatusEnvelopeResponse = z.infer<typeof ZSigningStatusEnvelopeResponseSchema>;

View File

@ -127,11 +127,11 @@ export const DocumentShareButton = ({
<Button <Button
variant="outline" variant="outline"
disabled={!token || !documentId} disabled={!token || !documentId}
className={cn('flex-1 text-[11px]', className)} className={cn('h-11 w-full max-w-lg flex-1', className)}
loading={isLoading} loading={isLoading}
> >
{!isLoading && <Sparkles className="mr-2 h-5 w-5" />} {!isLoading && <Sparkles className="mr-2 h-5 w-5" />}
<Trans>Share Signature Card</Trans> <Trans>Share</Trans>
</Button> </Button>
)} )}
</DialogTrigger> </DialogTrigger>

View File

@ -56,6 +56,7 @@ export type PDFViewerProps = {
className?: string; className?: string;
envelopeItem: Pick<EnvelopeItem, 'id' | 'envelopeId'>; envelopeItem: Pick<EnvelopeItem, 'id' | 'envelopeId'>;
token: string | undefined; token: string | undefined;
presignToken?: string | undefined;
version: 'original' | 'signed'; version: 'original' | 'signed';
onDocumentLoad?: (_doc: LoadedPDFDocument) => void; onDocumentLoad?: (_doc: LoadedPDFDocument) => void;
onPageClick?: OnPDFViewerPageClick; onPageClick?: OnPDFViewerPageClick;
@ -67,6 +68,7 @@ export const PDFViewer = ({
className, className,
envelopeItem, envelopeItem,
token, token,
presignToken,
version, version,
onDocumentLoad, onDocumentLoad,
onPageClick, onPageClick,
@ -166,6 +168,7 @@ export const PDFViewer = ({
type: 'view', type: 'view',
envelopeItem: envelopeItem, envelopeItem: envelopeItem,
token, token,
presignToken,
}); });
const bytes = await fetch(documentUrl).then(async (res) => await res.arrayBuffer()); const bytes = await fetch(documentUrl).then(async (res) => await res.arrayBuffer());

View File

@ -119,6 +119,7 @@
"GOOGLE_APPLICATION_CREDENTIALS", "GOOGLE_APPLICATION_CREDENTIALS",
"E2E_TEST_AUTHENTICATE_USERNAME", "E2E_TEST_AUTHENTICATE_USERNAME",
"E2E_TEST_AUTHENTICATE_USER_EMAIL", "E2E_TEST_AUTHENTICATE_USER_EMAIL",
"E2E_TEST_AUTHENTICATE_USER_PASSWORD" "E2E_TEST_AUTHENTICATE_USER_PASSWORD",
"NEXT_PRIVATE_OIDC_PROMPT"
] ]
} }