mirror of
https://github.com/documenso/documenso.git
synced 2025-11-20 19:51:32 +10:00
Compare commits
29 Commits
v2.0.13
...
feat/auto-
| Author | SHA1 | Date | |
|---|---|---|---|
| 15e2988f9e | |||
| 17c6098638 | |||
| e5bde53ee4 | |||
| 0663605ffd | |||
| 1bbe561162 | |||
| fbc156722a | |||
| f5d63fb76c | |||
| 374477e692 | |||
| 11d9bde8f8 | |||
| 86d11cc720 | |||
| c615e30633 | |||
| 358ba2dd6f | |||
| 7fa86fe297 | |||
| 0b91d33bfb | |||
| e010238bcc | |||
| 498a2be1c7 | |||
| 3e84aa632f | |||
| a08a77e98b | |||
| 13d9ca7a0e | |||
| d25565b7d0 | |||
| 91421a7d62 | |||
| a9f1e39b10 | |||
| b37748654e | |||
| b3ed80d721 | |||
| b3cb750470 | |||
| 1e52493144 | |||
| ab95e80987 | |||
| 1780a5c262 | |||
| cb9bf407f7 |
@ -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"
|
||||||
|
|||||||
@ -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,56 +544,50 @@ export const ConfigureFieldsView = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
{normalizedDocumentData && (
|
<div>
|
||||||
<div>
|
<PDFViewer
|
||||||
<PDFViewer
|
presignToken={presignToken}
|
||||||
overrideData={normalizedDocumentData}
|
overrideData={normalizedDocumentData}
|
||||||
envelopeItem={{
|
envelopeItem={normalizedEnvelopeItem}
|
||||||
id: '',
|
token={undefined}
|
||||||
envelopeId: '',
|
version="signed"
|
||||||
}}
|
/>
|
||||||
token={undefined}
|
|
||||||
version="signed"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ElementVisible
|
<ElementVisible
|
||||||
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
|
||||||
key={field.formId}
|
key={field.formId}
|
||||||
field={field}
|
field={field}
|
||||||
minHeight={MIN_HEIGHT_PX}
|
minHeight={MIN_HEIGHT_PX}
|
||||||
minWidth={MIN_WIDTH_PX}
|
minWidth={MIN_WIDTH_PX}
|
||||||
defaultHeight={DEFAULT_HEIGHT_PX}
|
defaultHeight={DEFAULT_HEIGHT_PX}
|
||||||
defaultWidth={DEFAULT_WIDTH_PX}
|
defaultWidth={DEFAULT_WIDTH_PX}
|
||||||
onResize={(node) => onFieldResize(node, index)}
|
onResize={(node) => onFieldResize(node, index)}
|
||||||
onMove={(node) => onFieldMove(node, index)}
|
onMove={(node) => onFieldMove(node, index)}
|
||||||
onRemove={() => remove(index)}
|
onRemove={() => remove(index)}
|
||||||
onDuplicate={() => onFieldCopy(null, { duplicate: true })}
|
onDuplicate={() => onFieldCopy(null, { duplicate: true })}
|
||||||
onDuplicateAllPages={() => onFieldCopy(null, { duplicateAll: true })}
|
onDuplicateAllPages={() => onFieldCopy(null, { duplicateAll: true })}
|
||||||
onFocus={() => setLastActiveField(field)}
|
onFocus={() => setLastActiveField(field)}
|
||||||
onBlur={() => setLastActiveField(null)}
|
onBlur={() => setLastActiveField(null)}
|
||||||
onAdvancedSettings={() => {
|
onAdvancedSettings={() => {
|
||||||
setCurrentField(field);
|
setCurrentField(field);
|
||||||
setShowAdvancedSettings(true);
|
setShowAdvancedSettings(true);
|
||||||
}}
|
}}
|
||||||
recipientIndex={recipientIndex}
|
recipientIndex={recipientIndex}
|
||||||
active={activeFieldId === field.formId}
|
active={activeFieldId === field.formId}
|
||||||
onFieldActivate={() => setActiveFieldId(field.formId)}
|
onFieldActivate={() => setActiveFieldId(field.formId)}
|
||||||
onFieldDeactivate={() => setActiveFieldId(null)}
|
onFieldDeactivate={() => setActiveFieldId(null)}
|
||||||
disabled={selectedRecipient?.id !== field.recipientId}
|
disabled={selectedRecipient?.id !== field.recipientId}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ElementVisible>
|
</ElementVisible>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</Form>
|
</Form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -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 <></>;
|
|
||||||
};
|
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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
19
package-lock.json
generated
@ -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",
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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.
BIN
packages/assets/project-proposal-single-recipient.pdf
Normal file
BIN
packages/assets/project-proposal-single-recipient.pdf
Normal file
Binary file not shown.
@ -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.
|
||||||
url.searchParams.append('prompt', prompt);
|
if (process.env.NEXT_PRIVATE_OIDC_PROMPT !== '') {
|
||||||
|
const prompt = process.env.NEXT_PRIVATE_OIDC_PROMPT ?? 'login';
|
||||||
|
|
||||||
|
url.searchParams.append('prompt', prompt);
|
||||||
|
}
|
||||||
|
|
||||||
setCookie(c, `${clientOptions.id}_oauth_state`, state, {
|
setCookie(c, `${clientOptions.id}_oauth_state`, state, {
|
||||||
...sessionCookieOptions,
|
...sessionCookieOptions,
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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,171 +61,120 @@ export const run = async ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { documentId, sendEmail = true, isResealing = false, requestMetadata } = payload;
|
const { documentId, sendEmail = true, isResealing = false, requestMetadata } = payload;
|
||||||
|
|
||||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
const { envelopeId, envelopeStatus, isRejected } = await io.runTask('seal-document', async () => {
|
||||||
where: {
|
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||||
type: EnvelopeType.DOCUMENT,
|
where: {
|
||||||
secondaryId: mapDocumentIdToSecondaryId(documentId),
|
type: EnvelopeType.DOCUMENT,
|
||||||
},
|
secondaryId: mapDocumentIdToSecondaryId(documentId),
|
||||||
include: {
|
},
|
||||||
documentMeta: true,
|
include: {
|
||||||
recipients: true,
|
documentMeta: true,
|
||||||
envelopeItems: {
|
recipients: true,
|
||||||
include: {
|
envelopeItems: {
|
||||||
documentData: true,
|
include: {
|
||||||
field: {
|
documentData: true,
|
||||||
include: {
|
field: {
|
||||||
signature: true,
|
include: {
|
||||||
|
signature: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (envelope.envelopeItems.length === 0) {
|
|
||||||
throw new Error('At least one envelope item required');
|
|
||||||
}
|
|
||||||
|
|
||||||
const settings = await getTeamSettings({
|
|
||||||
userId: envelope.userId,
|
|
||||||
teamId: envelope.teamId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const isComplete =
|
|
||||||
envelope.recipients.some((recipient) => recipient.signingStatus === SigningStatus.REJECTED) ||
|
|
||||||
envelope.recipients.every((recipient) => recipient.signingStatus === SigningStatus.SIGNED);
|
|
||||||
|
|
||||||
if (!isComplete) {
|
|
||||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
|
||||||
message: 'Document is not complete',
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// Seems silly but we need to do this in case the job is re-ran
|
if (envelope.envelopeItems.length === 0) {
|
||||||
// after it has already run through the update task further below.
|
throw new Error('At least one envelope item required');
|
||||||
// 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.
|
const settings = await getTeamSettings({
|
||||||
let envelopeItems = await io.runTask(
|
userId: envelope.userId,
|
||||||
'get-document-data-id',
|
teamId: envelope.teamId,
|
||||||
// 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) {
|
const isComplete =
|
||||||
throw new Error(`Document ${envelope.id} has no envelope items`);
|
envelope.recipients.some((recipient) => recipient.signingStatus === SigningStatus.REJECTED) ||
|
||||||
}
|
envelope.recipients.every((recipient) => recipient.signingStatus === SigningStatus.SIGNED);
|
||||||
|
|
||||||
const recipients = await prisma.recipient.findMany({
|
if (!isComplete) {
|
||||||
where: {
|
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
||||||
envelopeId: envelope.id,
|
message: 'Document is not complete',
|
||||||
role: {
|
});
|
||||||
not: RecipientRole.CC,
|
}
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Determine if the document has been rejected by checking if any recipient has rejected it
|
let envelopeItems = envelope.envelopeItems;
|
||||||
const rejectedRecipient = recipients.find(
|
|
||||||
(recipient) => recipient.signingStatus === SigningStatus.REJECTED,
|
|
||||||
);
|
|
||||||
|
|
||||||
const isRejected = Boolean(rejectedRecipient);
|
if (envelopeItems.length < 1) {
|
||||||
|
throw new Error(`Document ${envelope.id} has no envelope items`);
|
||||||
|
}
|
||||||
|
|
||||||
// Get the rejection reason from the rejected recipient
|
const recipients = await prisma.recipient.findMany({
|
||||||
const rejectionReason = rejectedRecipient?.rejectionReason ?? '';
|
|
||||||
|
|
||||||
const fields = await prisma.field.findMany({
|
|
||||||
where: {
|
|
||||||
envelopeId: envelope.id,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
signature: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Skip the field check if the document is rejected
|
|
||||||
if (!isRejected && fieldsContainUnsignedRequiredField(fields)) {
|
|
||||||
throw new Error(`Document ${envelope.id} has unsigned required fields`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isResealing) {
|
|
||||||
// If we're resealing we want to use the initial data for the document
|
|
||||||
// so we aren't placing fields on top of eachother.
|
|
||||||
envelopeItems = envelopeItems.map((envelopeItem) => ({
|
|
||||||
...envelopeItem,
|
|
||||||
documentData: {
|
|
||||||
...envelopeItem.documentData,
|
|
||||||
data: envelopeItem.documentData.initialData,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!envelope.qrToken) {
|
|
||||||
await prisma.envelope.update({
|
|
||||||
where: {
|
where: {
|
||||||
id: envelope.id,
|
envelopeId: envelope.id,
|
||||||
},
|
role: {
|
||||||
data: {
|
not: RecipientRole.CC,
|
||||||
qrToken: prefixedId('qr'),
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
|
// Determine if the document has been rejected by checking if any recipient has rejected it
|
||||||
|
const rejectedRecipient = recipients.find(
|
||||||
|
(recipient) => recipient.signingStatus === SigningStatus.REJECTED,
|
||||||
|
);
|
||||||
|
|
||||||
const { certificateData, auditLogData } = await getCertificateAndAuditLogData({
|
const isRejected = Boolean(rejectedRecipient);
|
||||||
legacyDocumentId,
|
|
||||||
documentMeta: envelope.documentMeta,
|
|
||||||
settings,
|
|
||||||
});
|
|
||||||
|
|
||||||
// !: The commented out code is our desired implementation but we're seemingly
|
// Get the rejection reason from the rejected recipient
|
||||||
// !: running into issues with inngest parallelism in production.
|
const rejectionReason = rejectedRecipient?.rejectionReason ?? '';
|
||||||
// !: 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 fields = await prisma.field.findMany({
|
||||||
// const task = io.runTask(`decorate-${envelopeItem.id}`, async () => {
|
where: {
|
||||||
// const envelopeItemFields = envelope.envelopeItems.find(
|
envelopeId: envelope.id,
|
||||||
// (item) => item.id === envelopeItem.id,
|
},
|
||||||
// )?.field;
|
include: {
|
||||||
|
signature: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// if (!envelopeItemFields) {
|
// Skip the field check if the document is rejected
|
||||||
// throw new Error(`Envelope item fields not found for envelope item ${envelopeItem.id}`);
|
if (!isRejected && fieldsContainUnsignedRequiredField(fields)) {
|
||||||
// }
|
throw new Error(`Document ${envelope.id} has unsigned required fields`);
|
||||||
|
}
|
||||||
|
|
||||||
// return decorateAndSignPdf({
|
if (isResealing) {
|
||||||
// envelope,
|
// If we're resealing we want to use the initial data for the document
|
||||||
// envelopeItem,
|
// so we aren't placing fields on top of eachother.
|
||||||
// envelopeItemFields,
|
envelopeItems = envelopeItems.map((envelopeItem) => ({
|
||||||
// isRejected,
|
...envelopeItem,
|
||||||
// rejectionReason,
|
documentData: {
|
||||||
// certificateData,
|
...envelopeItem.documentData,
|
||||||
// auditLogData,
|
data: envelopeItem.documentData.initialData,
|
||||||
// });
|
},
|
||||||
// });
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
// decoratePromises.push(task);
|
if (!envelope.qrToken) {
|
||||||
// }
|
await prisma.envelope.update({
|
||||||
|
where: {
|
||||||
|
id: envelope.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
qrToken: prefixedId('qr'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// const newDocumentData = await Promise.all(decoratePromises);
|
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
|
||||||
|
|
||||||
// TODO: Remove once parallelization is working
|
const { certificateData, auditLogData } = await getCertificateAndAuditLogData({
|
||||||
const newDocumentData: Array<{ oldDocumentDataId: string; newDocumentDataId: string }> = [];
|
legacyDocumentId,
|
||||||
|
documentMeta: envelope.documentMeta,
|
||||||
|
settings,
|
||||||
|
});
|
||||||
|
|
||||||
for (const envelopeItem of envelopeItems) {
|
const newDocumentData: Array<{ oldDocumentDataId: string; newDocumentDataId: string }> = [];
|
||||||
const result = await io.runTask(`decorate-${envelopeItem.id}`, async () => {
|
|
||||||
|
for (const envelopeItem of envelopeItems) {
|
||||||
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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
347
packages/lib/server-only/pdf/auto-place-fields.ts
Normal file
347
packages/lib/server-only/pdf/auto-place-fields.ts
Normal 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;
|
||||||
|
};
|
||||||
266
packages/lib/server-only/pdf/helpers.ts
Normal file
266
packages/lib/server-only/pdf/helpers.ts
Normal 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];
|
||||||
|
};
|
||||||
@ -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);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
10966
packages/lib/translations/pt-BR/web.po
Normal file
10966
packages/lib/translations/pt-BR/web.po
Normal file
File diff suppressed because it is too large
Load Diff
@ -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}`;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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',
|
||||||
|
};
|
||||||
|
});
|
||||||
@ -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>;
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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());
|
||||||
|
|||||||
@ -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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user