Compare commits

...

5 Commits

18 changed files with 648 additions and 141 deletions

View File

@ -89,7 +89,10 @@ export const DirectTemplatePageView = ({
setStep('sign'); setStep('sign');
}; };
const onSignDirectTemplateSubmit = async (fields: DirectTemplateLocalField[]) => { const onSignDirectTemplateSubmit = async (
fields: DirectTemplateLocalField[],
nextSigner?: { name: string; email: string },
) => {
try { try {
let directTemplateExternalId = searchParams?.get('externalId') || undefined; let directTemplateExternalId = searchParams?.get('externalId') || undefined;
@ -98,6 +101,7 @@ export const DirectTemplatePageView = ({
} }
const { token } = await createDocumentFromDirectTemplate({ const { token } = await createDocumentFromDirectTemplate({
nextSigner,
directTemplateToken, directTemplateToken,
directTemplateExternalId, directTemplateExternalId,
directRecipientName: fullName, directRecipientName: fullName,

View File

@ -55,10 +55,13 @@ import { DocumentSigningRecipientProvider } from '../document-signing/document-s
export type DirectTemplateSigningFormProps = { export type DirectTemplateSigningFormProps = {
flowStep: DocumentFlowStep; flowStep: DocumentFlowStep;
directRecipient: Pick<Recipient, 'authOptions' | 'email' | 'role' | 'name' | 'token'>; directRecipient: Pick<Recipient, 'authOptions' | 'email' | 'role' | 'name' | 'token' | 'id'>;
directRecipientFields: Field[]; directRecipientFields: Field[];
template: Omit<TTemplate, 'user'>; template: Omit<TTemplate, 'user'>;
onSubmit: (_data: DirectTemplateLocalField[]) => Promise<void>; onSubmit: (
_data: DirectTemplateLocalField[],
_nextSigner?: { name: string; email: string },
) => Promise<void>;
}; };
export type DirectTemplateLocalField = Field & { export type DirectTemplateLocalField = Field & {
@ -149,7 +152,7 @@ export const DirectTemplateSigningForm = ({
validateFieldsInserted(fieldsRequiringValidation); validateFieldsInserted(fieldsRequiringValidation);
}; };
const handleSubmit = async () => { const handleSubmit = async (nextSigner?: { name: string; email: string }) => {
setValidateUninsertedFields(true); setValidateUninsertedFields(true);
const isFieldsValid = validateFieldsInserted(fieldsRequiringValidation); const isFieldsValid = validateFieldsInserted(fieldsRequiringValidation);
@ -161,7 +164,7 @@ export const DirectTemplateSigningForm = ({
setIsSubmitting(true); setIsSubmitting(true);
try { try {
await onSubmit(localFields); await onSubmit(localFields, nextSigner);
} catch { } catch {
setIsSubmitting(false); setIsSubmitting(false);
} }
@ -218,6 +221,30 @@ export const DirectTemplateSigningForm = ({
setLocalFields(updatedFields); setLocalFields(updatedFields);
}, []); }, []);
const nextRecipient = useMemo(() => {
if (
!template.templateMeta?.signingOrder ||
template.templateMeta.signingOrder !== 'SEQUENTIAL' ||
!template.templateMeta.allowDictateNextSigner
) {
return undefined;
}
const sortedRecipients = template.recipients.sort((a, b) => {
// Sort by signingOrder first (nulls last), then by id
if (a.signingOrder === null && b.signingOrder === null) return a.id - b.id;
if (a.signingOrder === null) return 1;
if (b.signingOrder === null) return -1;
if (a.signingOrder === b.signingOrder) return a.id - b.id;
return a.signingOrder - b.signingOrder;
});
const currentIndex = sortedRecipients.findIndex((r) => r.id === directRecipient.id);
return currentIndex !== -1 && currentIndex < sortedRecipients.length - 1
? sortedRecipients[currentIndex + 1]
: undefined;
}, [template.templateMeta?.signingOrder, template.recipients, directRecipient.id]);
return ( return (
<DocumentSigningRecipientProvider recipient={directRecipient}> <DocumentSigningRecipientProvider recipient={directRecipient}>
<DocumentFlowFormContainerHeader title={flowStep.title} description={flowStep.description} /> <DocumentFlowFormContainerHeader title={flowStep.title} description={flowStep.description} />
@ -417,11 +444,15 @@ export const DirectTemplateSigningForm = ({
<DocumentSigningCompleteDialog <DocumentSigningCompleteDialog
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
onSignatureComplete={async () => handleSubmit()} onSignatureComplete={async (nextSigner) => handleSubmit(nextSigner)}
documentTitle={template.title} documentTitle={template.title}
fields={localFields} fields={localFields}
fieldsValidated={fieldsValidated} fieldsValidated={fieldsValidated}
recipient={directRecipient} recipient={directRecipient}
allowDictateNextSigner={nextRecipient && template.templateMeta?.allowDictateNextSigner}
defaultNextSigner={
nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined
}
/> />
</div> </div>
</DocumentFlowFormContainerFooter> </DocumentFlowFormContainerFooter>

View File

@ -9,7 +9,7 @@ import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
export type DocumentSigningAuthPageViewProps = { export type DocumentSigningAuthPageViewProps = {
email: string; email?: string;
emailHasAccount?: boolean; emailHasAccount?: boolean;
}; };
@ -22,12 +22,18 @@ export const DocumentSigningAuthPageView = ({
const [isSigningOut, setIsSigningOut] = useState(false); const [isSigningOut, setIsSigningOut] = useState(false);
const handleChangeAccount = async (email: string) => { const handleChangeAccount = async (email?: string) => {
try { try {
setIsSigningOut(true); setIsSigningOut(true);
let redirectPath = '/signin';
if (email) {
redirectPath = emailHasAccount ? `/signin#email=${email}` : `/signup#email=${email}`;
}
await authClient.signOut({ await authClient.signOut({
redirectPath: emailHasAccount ? `/signin#email=${email}` : `/signup#email=${email}`, redirectPath,
}); });
} catch { } catch {
toast({ toast({
@ -49,9 +55,13 @@ export const DocumentSigningAuthPageView = ({
</h1> </h1>
<p className="text-muted-foreground mt-2 text-sm"> <p className="text-muted-foreground mt-2 text-sm">
<Trans> {email ? (
You need to be logged in as <strong>{email}</strong> to view this page. <Trans>
</Trans> You need to be logged in as <strong>{email}</strong> to view this page.
</Trans>
) : (
<Trans>You need to be logged in to view this page.</Trans>
)}
</p> </p>
<Button <Button

View File

@ -24,7 +24,10 @@ type PasskeyData = {
isError: boolean; isError: boolean;
}; };
type SigningAuthRecipient = Pick<Recipient, 'authOptions' | 'email' | 'role' | 'name' | 'token'>; type SigningAuthRecipient = Pick<
Recipient,
'authOptions' | 'email' | 'role' | 'name' | 'token' | 'id'
>;
export type DocumentSigningAuthContextValue = { export type DocumentSigningAuthContextValue = {
executeActionAuthProcedure: (_value: ExecuteActionAuthProcedureOptions) => Promise<void>; executeActionAuthProcedure: (_value: ExecuteActionAuthProcedureOptions) => Promise<void>;

View File

@ -304,7 +304,6 @@ export const DocumentSigningCompleteDialog = ({
<form onSubmit={form.handleSubmit(onFormSubmit)}> <form onSubmit={form.handleSubmit(onFormSubmit)}>
{allowDictateNextSigner && defaultNextSigner && ( {allowDictateNextSigner && defaultNextSigner && (
<div className="mb-4 flex flex-col gap-4"> <div className="mb-4 flex flex-col gap-4">
{/* Todo: Envelopes - Should we say "The next recipient to sign this document will be"? */}
<div className="flex flex-col gap-4 md:flex-row"> <div className="flex flex-col gap-4 md:flex-row">
<FormField <FormField
control={form.control} control={form.control}

View File

@ -285,8 +285,6 @@ export const EnvelopeSigningProvider = ({
}, [envelope.documentMeta?.signingOrder, envelope.recipients, recipient.id]); }, [envelope.documentMeta?.signingOrder, envelope.recipients, recipient.id]);
const signField = async (fieldId: number, fieldValue: TSignEnvelopeFieldValue) => { const signField = async (fieldId: number, fieldValue: TSignEnvelopeFieldValue) => {
console.log('insertField', fieldId, fieldValue);
// Set the field locally for direct templates. // Set the field locally for direct templates.
if (isDirectTemplate) { if (isDirectTemplate) {
handleDirectTemplateFieldInsertion(fieldId, fieldValue); handleDirectTemplateFieldInsertion(fieldId, fieldValue);

View File

@ -127,6 +127,7 @@ export const EnvelopeSignerCompleteDialog = () => {
isBase64, isBase64,
}; };
}), }),
nextSigner,
}); });
const redirectUrl = envelope.documentMeta.redirectUrl; const redirectUrl = envelope.documentMeta.redirectUrl;

View File

@ -8,7 +8,6 @@ import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/env
import { useOptionalSession } from '@documenso/lib/client-only/providers/session'; import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getEnvelopeForDirectTemplateSigning } from '@documenso/lib/server-only/envelope/get-envelope-for-direct-template-signing'; import { getEnvelopeForDirectTemplateSigning } from '@documenso/lib/server-only/envelope/get-envelope-for-direct-template-signing';
import { getEnvelopeRequiredAccessData } from '@documenso/lib/server-only/envelope/get-envelope-required-access-data';
import { getTemplateByDirectLinkToken } from '@documenso/lib/server-only/template/get-template-by-direct-link-token'; import { getTemplateByDirectLinkToken } from '@documenso/lib/server-only/template/get-template-by-direct-link-token';
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth'; import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth'; import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
@ -98,15 +97,12 @@ const handleV2Loader = async ({ params, request }: Route.LoaderArgs) => {
envelopeForSigning, envelopeForSigning,
} as const; } as const;
}) })
.catch(async (e) => { .catch((e) => {
const error = AppError.parseError(e); const error = AppError.parseError(e);
if (error.code === AppErrorCode.UNAUTHORIZED) { if (error.code === AppErrorCode.UNAUTHORIZED) {
const requiredAccessData = await getEnvelopeRequiredAccessData({ token });
return { return {
isDocumentAccessValid: false, isDocumentAccessValid: false,
...requiredAccessData,
} as const; } as const;
} }
@ -226,20 +222,21 @@ const DirectSigningPageV2 = ({ data }: { data: Awaited<ReturnType<typeof handleV
const user = sessionData?.user; const user = sessionData?.user;
if (!data.isDocumentAccessValid) { if (!data.isDocumentAccessValid) {
return ( return <DocumentSigningAuthPageView email={''} emailHasAccount={true} />;
<DocumentSigningAuthPageView
email={data.recipientEmail}
emailHasAccount={!!data.recipientHasAccount}
/>
);
} }
const { envelope, recipient } = data.envelopeForSigning; const { envelope, recipient } = data.envelopeForSigning;
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
documentAuth: envelope.authOptions,
});
const isEmailForced = derivedRecipientAccessAuth.includes(DocumentAccessAuth.ACCOUNT);
return ( return (
<EnvelopeSigningProvider <EnvelopeSigningProvider
envelopeData={data.envelopeForSigning} envelopeData={data.envelopeForSigning}
email={''} // Doing this allows us to let users change the email if they want to. email={isEmailForced ? user?.email || '' : ''} // Doing this allows us to let users change the email if they want to for non-auth templates.
fullName={user?.name} fullName={user?.name}
signature={user?.signature} signature={user?.signature}
> >

View File

@ -0,0 +1,98 @@
import { expect, test } from '@playwright/test';
import path from 'path';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
const PLACEHOLDER_PDF_PATH = path.join(
__dirname,
'../../../assets/project-proposal-single-recipient.pdf',
);
test.describe('PDF Placeholders with single recipient', () => {
test('[AUTO_PLACING_FIELDS]: should automatically create recipients from PDF placeholders', async ({
page,
}) => {
const { user, team } = await seedUser();
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents`,
});
const fileInput = page.locator('input[type="file"]').nth(1);
await fileInput.waitFor({ state: 'attached' });
await fileInput.setInputFiles(PLACEHOLDER_PDF_PATH);
await page.waitForTimeout(3000);
await page.getByRole('button', { name: 'Continue' }).click();
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,
}) => {
const { user, team } = await seedUser();
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents`,
});
const fileInput = page.locator('input[type="file"]').nth(1);
await fileInput.waitFor({ state: 'attached' });
await fileInput.setInputFiles(PLACEHOLDER_PDF_PATH);
await page.waitForTimeout(3000);
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByRole('button', { name: 'Continue' }).click();
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,
}) => {
const { user, team } = await seedUser();
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents`,
});
const fileInput = page.locator('input[type="file"]').nth(1);
await fileInput.waitFor({ state: 'attached' });
await fileInput.setInputFiles(PLACEHOLDER_PDF_PATH);
await page.waitForTimeout(3000);
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByRole('button', { name: 'Continue' }).click();
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');
});
});

View File

@ -1,9 +1,12 @@
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
import { customAlphabet } from 'nanoid'; import { customAlphabet } from 'nanoid';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createDocumentAuthOptions } from '@documenso/lib/utils/document-auth'; import { createDocumentAuthOptions } from '@documenso/lib/utils/document-auth';
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import { formatDirectTemplatePath } from '@documenso/lib/utils/templates'; import { formatDirectTemplatePath } from '@documenso/lib/utils/templates';
import { prisma } from '@documenso/prisma';
import { seedTeam } from '@documenso/prisma/seed/teams'; import { seedTeam } from '@documenso/prisma/seed/teams';
import { seedDirectTemplate, seedTemplate } from '@documenso/prisma/seed/templates'; import { seedDirectTemplate, seedTemplate } from '@documenso/prisma/seed/templates';
import { seedTestEmail, seedUser } from '@documenso/prisma/seed/users'; import { seedTestEmail, seedUser } from '@documenso/prisma/seed/users';
@ -121,7 +124,7 @@ test('[DIRECT_TEMPLATES]: delete direct template link', async ({ page }) => {
await expect(page.getByText('404 not found')).toBeVisible(); await expect(page.getByText('404 not found')).toBeVisible();
}); });
test('[DIRECT_TEMPLATES]: direct template link auth access', async ({ page }) => { test('[DIRECT_TEMPLATES]: V1 direct template link auth access', async ({ page }) => {
const { user, team } = await seedUser(); const { user, team } = await seedUser();
const directTemplateWithAuth = await seedDirectTemplate({ const directTemplateWithAuth = await seedDirectTemplate({
@ -153,6 +156,53 @@ test('[DIRECT_TEMPLATES]: direct template link auth access', async ({ page }) =>
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
await expect(page.getByLabel('Email')).toBeDisabled(); await expect(page.getByLabel('Email')).toBeDisabled();
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByRole('button', { name: 'Complete' }).click();
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(/\/sign/);
await expect(page.getByRole('heading', { name: 'Document Signed' })).toBeVisible();
});
test('[DIRECT_TEMPLATES]: V2 direct template link auth access', async ({ page }) => {
const { user, team } = await seedUser();
const directTemplateWithAuth = await seedDirectTemplate({
title: 'Personal direct template link',
userId: user.id,
teamId: team.id,
internalVersion: 2,
createTemplateOptions: {
authOptions: createDocumentAuthOptions({
globalAccessAuth: ['ACCOUNT'],
globalActionAuth: [],
}),
},
});
const directTemplatePath = formatDirectTemplatePath(
directTemplateWithAuth.directLink?.token || '',
);
await page.goto(directTemplatePath);
await expect(page.getByText('Authentication required')).toBeVisible();
await apiSignin({
page,
email: user.email,
});
await page.goto(directTemplatePath);
await expect(page.getByRole('heading', { name: 'Personal direct template link' })).toBeVisible();
await page.getByRole('button', { name: 'Complete' }).click();
await expect(page.getByLabel('Your Email')).not.toBeVisible();
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(/\/sign/);
await expect(page.getByRole('heading', { name: 'Document Signed' })).toBeVisible();
}); });
test('[DIRECT_TEMPLATES]: use direct template link with 1 recipient', async ({ page }) => { test('[DIRECT_TEMPLATES]: use direct template link with 1 recipient', async ({ page }) => {
@ -175,6 +225,9 @@ test('[DIRECT_TEMPLATES]: use direct template link with 1 recipient', async ({ p
await page.getByPlaceholder('recipient@documenso.com').fill(seedTestEmail()); await page.getByPlaceholder('recipient@documenso.com').fill(seedTestEmail());
await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByText('Next Recipient Name')).not.toBeVisible();
await page.getByRole('button', { name: 'Complete' }).click(); await page.getByRole('button', { name: 'Complete' }).click();
await page.getByRole('button', { name: 'Sign' }).click(); await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(/\/sign/); await page.waitForURL(/\/sign/);
@ -183,3 +236,173 @@ test('[DIRECT_TEMPLATES]: use direct template link with 1 recipient', async ({ p
// Add a longer waiting period to ensure document status is updated // Add a longer waiting period to ensure document status is updated
await page.waitForTimeout(3000); await page.waitForTimeout(3000);
}); });
test('[DIRECT_TEMPLATES]: V1 use direct template link with 2 recipients with next signer dictation', async ({
page,
}) => {
const { team, owner, organisation } = await seedTeam({
createTeamMembers: 1,
});
// Should be visible to team members.
const template = await seedDirectTemplate({
title: 'Team direct template link 1',
userId: owner.id,
teamId: team.id,
});
await prisma.documentMeta.update({
where: {
id: template.documentMetaId,
},
data: {
allowDictateNextSigner: true,
signingOrder: DocumentSigningOrder.SEQUENTIAL,
},
});
const originalName = 'Signer 2';
const originalSecondSignerEmail = seedTestEmail();
// Add another signer
await prisma.recipient.create({
data: {
signingOrder: 2,
envelopeId: template.id,
email: originalSecondSignerEmail,
name: originalName,
token: Math.random().toString().slice(2, 7),
role: RecipientRole.SIGNER,
},
});
// Check that the direct template link is accessible.
await page.goto(formatDirectTemplatePath(template.directLink?.token || ''));
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
await page.waitForTimeout(100);
await page.getByPlaceholder('recipient@documenso.com').fill(seedTestEmail());
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByRole('button', { name: 'Complete' }).click();
await expect(page.getByText('Next Recipient Name')).toBeVisible();
const nextRecipientNameInputValue = await page.getByLabel('Next Recipient Name').inputValue();
expect(nextRecipientNameInputValue).toBe(originalName);
const nextRecipientEmailInputValue = await page.getByLabel('Next Recipient Email').inputValue();
expect(nextRecipientEmailInputValue).toBe(originalSecondSignerEmail);
const newName = 'Hello';
const newSecondSignerEmail = seedTestEmail();
await page.getByLabel('Next Recipient Email').fill(newSecondSignerEmail);
await page.getByLabel('Next Recipient Name').fill(newName);
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(/\/sign/);
await expect(page.getByRole('heading', { name: 'Document Signed' })).toBeVisible();
const createdEnvelopeRecipients = await prisma.recipient.findMany({
where: {
envelope: {
templateId: mapSecondaryIdToTemplateId(template.secondaryId),
},
},
});
const updatedSecondRecipient = createdEnvelopeRecipients.find(
(recipient) => recipient.signingOrder === 2,
);
expect(updatedSecondRecipient?.name).toBe(newName);
expect(updatedSecondRecipient?.email).toBe(newSecondSignerEmail);
});
test('[DIRECT_TEMPLATES]: V2 use direct template link with 2 recipients with next signer dictation', async ({
page,
}) => {
const { team, owner, organisation } = await seedTeam({
createTeamMembers: 1,
});
// Should be visible to team members.
const template = await seedDirectTemplate({
title: 'Team direct template link 1',
userId: owner.id,
teamId: team.id,
internalVersion: 2,
});
await prisma.documentMeta.update({
where: {
id: template.documentMetaId,
},
data: {
allowDictateNextSigner: true,
signingOrder: DocumentSigningOrder.SEQUENTIAL,
},
});
const originalName = 'Signer 2';
const originalSecondSignerEmail = seedTestEmail();
// Add another signer
await prisma.recipient.create({
data: {
signingOrder: 2,
envelopeId: template.id,
email: originalSecondSignerEmail,
name: originalName,
token: Math.random().toString().slice(2, 7),
role: RecipientRole.SIGNER,
},
});
// Check that the direct template link is accessible.
await page.goto(formatDirectTemplatePath(template.directLink?.token || ''));
await expect(page.getByRole('heading', { name: 'Team direct template link 1' })).toBeVisible();
await page.waitForTimeout(100);
await page.getByRole('button', { name: 'Complete' }).click();
const currentName = 'John Doe';
const currentEmail = seedTestEmail();
await page.getByPlaceholder('Enter Your Name').fill(currentName);
await page.getByPlaceholder('Enter Your Email').fill(currentEmail);
await expect(page.getByText('Next Recipient Name')).toBeVisible();
const nextRecipientNameInputValue = await page.getByLabel('Next Recipient Name').inputValue();
expect(nextRecipientNameInputValue).toBe(originalName);
const nextRecipientEmailInputValue = await page.getByLabel('Next Recipient Email').inputValue();
expect(nextRecipientEmailInputValue).toBe(originalSecondSignerEmail);
const newName = 'Hello';
const newSecondSignerEmail = seedTestEmail();
await page.getByLabel('Next Recipient Email').fill(newSecondSignerEmail);
await page.getByLabel('Next Recipient Name').fill(newName);
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(/\/sign/);
await expect(page.getByRole('heading', { name: 'Document Signed' })).toBeVisible();
const createdEnvelopeRecipients = await prisma.recipient.findMany({
where: {
envelope: {
templateId: mapSecondaryIdToTemplateId(template.secondaryId),
},
},
});
const updatedSecondRecipient = createdEnvelopeRecipients.find(
(recipient) => recipient.signingOrder === 2,
);
expect(updatedSecondRecipient?.name).toBe(newName);
expect(updatedSecondRecipient?.email).toBe(newSecondSignerEmail);
});

View File

@ -10,6 +10,7 @@ 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 { insertFieldsFromPlaceholdersInPDF } 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';
@ -233,7 +234,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'),
@ -353,8 +354,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,
},
},
}, },
}); });
@ -390,4 +395,51 @@ export const createEnvelope = async ({
return createdEnvelope; return createdEnvelope;
}); });
for (const envelopeItem of createdEnvelope.envelopeItems) {
const buffer = await getFileServerSide(envelopeItem.documentData);
// Use normalized PDF if normalizePdf was true, otherwise use original
const pdfToProcess = normalizePdf
? await makeNormalizedPdf(Buffer.from(buffer))
: Buffer.from(buffer);
await insertFieldsFromPlaceholdersInPDF(
pdfToProcess,
userId,
teamId,
{
type: 'envelopeId',
id: createdEnvelope.id,
},
requestMetadata,
envelopeItem.id,
);
}
const finalEnvelope = await prisma.envelope.findFirst({
where: {
id: createdEnvelope.id,
},
include: {
documentMeta: true,
recipients: true,
fields: true,
folder: true,
envelopeAttachments: true,
envelopeItems: {
include: {
documentData: true,
},
},
},
});
if (!finalEnvelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope not found',
});
}
return finalEnvelope;
}; };

View File

@ -1,10 +1,11 @@
import { DocumentStatus, EnvelopeType } from '@prisma/client'; import { DocumentStatus, EnvelopeType } from '@prisma/client';
import { match } from 'ts-pattern';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error'; import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TDocumentAuthMethods } from '../../types/document-auth'; import { DocumentAccessAuth, type TDocumentAuthMethods } from '../../types/document-auth';
import { isRecipientAuthorized } from '../document/is-recipient-authorized'; import { extractDocumentAuthMethods } from '../../utils/document-auth';
import { getTeamSettings } from '../team/get-team-settings'; import { getTeamSettings } from '../team/get-team-settings';
import type { EnvelopeForSigningResponse } from './get-envelope-for-recipient-signing'; import type { EnvelopeForSigningResponse } from './get-envelope-for-recipient-signing';
import { ZEnvelopeForSigningResponse } from './get-envelope-for-recipient-signing'; import { ZEnvelopeForSigningResponse } from './get-envelope-for-recipient-signing';
@ -98,14 +99,28 @@ export const getEnvelopeForDirectTemplateSigning = async ({
}); });
} }
const documentAccessValid = await isRecipientAuthorized({ // Currently not using this since for direct templates "User" access means they just need to be
type: 'ACCESS', // logged in.
documentAuthOptions: envelope.authOptions, // const documentAccessValid = await isRecipientAuthorized({
recipient, // type: 'ACCESS',
userId, // documentAuthOptions: envelope.authOptions,
authOptions: accessAuth, // recipient,
// userId,
// authOptions: accessAuth,
// });
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
documentAuth: envelope.authOptions,
}); });
// Ensure typesafety when we add more options.
const documentAccessValid = derivedRecipientAccessAuth.every((auth) =>
match(auth)
.with(DocumentAccessAuth.ACCOUNT, () => Boolean(userId))
.with(DocumentAccessAuth.TWO_FACTOR_AUTH, () => true)
.exhaustive(),
);
if (!documentAccessValid) { if (!documentAccessValid) {
throw new AppError(AppErrorCode.UNAUTHORIZED, { throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Invalid access values', message: 'Invalid access values',

View File

@ -54,54 +54,3 @@ export const getEnvelopeRequiredAccessData = async ({ token }: { token: string }
recipientHasAccount: Boolean(recipientUserAccount), recipientHasAccount: Boolean(recipientUserAccount),
} as const; } as const;
}; };
export const getEnvelopeDirectTemplateRequiredAccessData = async ({ token }: { token: string }) => {
const envelope = await prisma.envelope.findFirst({
where: {
type: EnvelopeType.TEMPLATE,
directLink: {
enabled: true,
token,
},
status: DocumentStatus.DRAFT,
},
include: {
recipients: {
where: {
token,
},
},
directLink: true,
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope not found',
});
}
const recipient = envelope.recipients.find(
(r) => r.id === envelope.directLink?.directTemplateRecipientId,
);
if (!recipient) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Recipient not found',
});
}
const recipientUserAccount = await prisma.user.findFirst({
where: {
email: recipient.email.toLowerCase(),
},
select: {
id: true,
},
});
return {
recipientEmail: recipient.email,
recipientHasAccount: Boolean(recipientUserAccount),
} as const;
};

View File

@ -13,7 +13,6 @@ import { type TFieldAndMeta, ZFieldAndMetaSchema } from '@documenso/lib/types/fi
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import type { EnvelopeIdOptions } from '@documenso/lib/utils/envelope'; import type { EnvelopeIdOptions } from '@documenso/lib/utils/envelope';
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope'; import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import { generateRecipientPlaceholder } from '@documenso/lib/utils/templates';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { getPageSize } from './get-page-size'; import { getPageSize } from './get-page-size';
@ -43,6 +42,7 @@ type PlaceholderInfo = {
}; };
type FieldToCreate = TFieldAndMeta & { type FieldToCreate = TFieldAndMeta & {
envelopeItemId?: string;
recipientId: number; recipientId: number;
pageNumber: number; pageNumber: number;
pageX: number; pageX: number;
@ -51,14 +51,20 @@ type FieldToCreate = TFieldAndMeta & {
height: number; height: number;
}; };
type RecipientPlaceholderInfo = {
email: string;
name: string;
recipientIndex: number;
};
/* /*
Questions for later: Questions for later:
- Does it handle multi-page PDFs? ✅ YES! ✅ - Does it handle multi-page PDFs? ✅ YES! ✅
- Does it handle multiple recipients on the same page? ✅ YES! ✅ - Does it handle multiple recipients on the same page? ✅ YES! ✅
- Does it handle multiple recipients on multiple pages? ✅ YES! ✅ - Does it handle multiple recipients on multiple pages? ✅ YES! ✅
- What happens with incorrect placeholders? E.g. those containing non-accepted properties. - What happens with incorrect placeholders? E.g. those containing non-accepted properties.
- The placeholder data is dynamic. How to handle this parsing? Perhaps we need to do it similar to the fieldMeta parsing. - The placeholder data is dynamic. How to handle this parsing? Perhaps we need to do it similar to the fieldMeta parsing.
- Need to handle envelopes with multiple items. - Need to handle envelopes with multiple items.
*/ */
/* /*
@ -196,9 +202,9 @@ export const extractPlaceholdersFromPDF = async (pdf: Buffer): Promise<Placehold
const placeholderMatches = pageText.matchAll(/{{([^}]+)}}/g); const placeholderMatches = pageText.matchAll(/{{([^}]+)}}/g);
for (const match of placeholderMatches) { for (const placeholderMatch of placeholderMatches) {
const placeholder = match[0]; const placeholder = placeholderMatch[0];
const placeholderData = match[1].split(',').map((part) => part.trim()); const placeholderData = placeholderMatch[1].split(',').map((part) => part.trim());
const [fieldTypeString, recipient, ...fieldMetaData] = placeholderData; const [fieldTypeString, recipient, ...fieldMetaData] = placeholderData;
@ -217,7 +223,12 @@ export const extractPlaceholdersFromPDF = async (pdf: Buffer): Promise<Placehold
Then find the position of where the placeholder ends in the text by adding the length of the placeholder to the index of the placeholder. Then find the position of where the placeholder ends in the text by adding the length of the placeholder to the index of the placeholder.
*/ */
const matchIndex = match.index; const matchIndex = placeholderMatch.index;
if (matchIndex === undefined) {
continue;
}
const placeholderLength = placeholder.length; const placeholderLength = placeholder.length;
const placeholderEndIndex = matchIndex + placeholderLength; const placeholderEndIndex = matchIndex + placeholderLength;
@ -316,9 +327,7 @@ export const replacePlaceholdersInPDF = async (pdf: Buffer): Promise<Buffer> =>
return Buffer.from(modifiedPdfBytes); return Buffer.from(modifiedPdfBytes);
}; };
const extractRecipientPlaceholder = ( const extractRecipientPlaceholder = (placeholder: string): RecipientPlaceholderInfo => {
placeholder: string,
): { email: string; recipientIndex: number } => {
const indexMatch = placeholder.match(/^r(\d+)$/i); const indexMatch = placeholder.match(/^r(\d+)$/i);
if (!indexMatch) { if (!indexMatch) {
@ -327,9 +336,12 @@ const extractRecipientPlaceholder = (
}); });
} }
const recipientIndex = Number(indexMatch[1]);
return { return {
email: `recipient.${indexMatch[1]}@documenso.com`, email: `recipient.${recipientIndex}@documenso.com`,
recipientIndex: Number(indexMatch[1]), name: `Recipient ${recipientIndex}`,
recipientIndex,
}; };
}; };
@ -339,6 +351,7 @@ export const insertFieldsFromPlaceholdersInPDF = async (
teamId: number, teamId: number,
envelopeId: EnvelopeIdOptions, envelopeId: EnvelopeIdOptions,
requestMetadata: ApiRequestMetadata, requestMetadata: ApiRequestMetadata,
envelopeItemId?: string,
): Promise<Buffer> => { ): Promise<Buffer> => {
const placeholders = await extractPlaceholdersFromPDF(pdf); const placeholders = await extractPlaceholdersFromPDF(pdf);
@ -347,15 +360,15 @@ export const insertFieldsFromPlaceholdersInPDF = async (
} }
/* /*
A structure that maps the recipient email to the recipient index. A structure that maps the recipient index to the recipient name.
Example: 'recipient.1@documenso.com' => 1 Example: 1 => 'Recipient 1'
*/ */
const recipientEmailToIndex = new Map<string, number>(); const recipientPlaceholders = new Map<number, string>();
for (const placeholder of placeholders) { for (const placeholder of placeholders) {
const { email, recipientIndex } = extractRecipientPlaceholder(placeholder.recipient); const { name, recipientIndex } = extractRecipientPlaceholder(placeholder.recipient);
recipientEmailToIndex.set(email, recipientIndex); recipientPlaceholders.set(recipientIndex, name);
} }
/* /*
@ -363,13 +376,11 @@ export const insertFieldsFromPlaceholdersInPDF = async (
Example: [{ email: 'recipient.1@documenso.com', name: 'Recipient 1', role: 'SIGNER', signingOrder: 1 }] Example: [{ email: 'recipient.1@documenso.com', name: 'Recipient 1', role: 'SIGNER', signingOrder: 1 }]
*/ */
const recipientsToCreate = Array.from( const recipientsToCreate = Array.from(
recipientEmailToIndex.entries(), recipientPlaceholders.entries(),
([email, recipientIndex]) => { ([recipientIndex, name]) => {
const placeholderInfo = generateRecipientPlaceholder(recipientIndex);
return { return {
email, email: `recipient.${recipientIndex}@documenso.com`,
name: placeholderInfo.name, name,
role: RecipientRole.SIGNER, role: RecipientRole.SIGNER,
signingOrder: recipientIndex, signingOrder: recipientIndex,
}; };
@ -398,36 +409,53 @@ export const insertFieldsFromPlaceholdersInPDF = async (
}); });
} }
let createdRecipients: Pick<Recipient, 'id' | 'email'>[]; const existingRecipients = await prisma.recipient.findMany({
where: {
envelopeId: envelope.id,
},
select: {
id: true,
email: true,
},
});
if (envelope.type === EnvelopeType.DOCUMENT) { const existingEmails = new Set(existingRecipients.map((r) => r.email.toLowerCase()));
const { recipients } = await createDocumentRecipients({ const recipientsToCreateFiltered = recipientsToCreate.filter(
userId, (r) => !existingEmails.has(r.email.toLowerCase()),
teamId, );
id: envelopeId,
recipients: recipientsToCreate,
requestMetadata,
});
createdRecipients = recipients; let createdRecipients: Pick<Recipient, 'id' | 'email'>[] = existingRecipients;
} else if (envelope.type === EnvelopeType.TEMPLATE) {
const templateId =
envelopeId.type === 'templateId'
? envelopeId.id
: mapSecondaryIdToTemplateId(envelope.secondaryId);
const { recipients } = await createTemplateRecipients({ if (recipientsToCreateFiltered.length > 0) {
userId, if (envelope.type === EnvelopeType.DOCUMENT) {
teamId, const { recipients } = await createDocumentRecipients({
templateId, userId,
recipients: recipientsToCreate, teamId,
}); id: envelopeId,
recipients: recipientsToCreateFiltered,
requestMetadata,
});
createdRecipients = recipients; createdRecipients = [...existingRecipients, ...recipients];
} else { } else if (envelope.type === EnvelopeType.TEMPLATE) {
throw new AppError(AppErrorCode.INVALID_BODY, { const templateId =
message: `Invalid envelope type: ${envelope.type}`, envelopeId.type === 'templateId'
}); ? envelopeId.id
: mapSecondaryIdToTemplateId(envelope.secondaryId);
const { recipients } = await createTemplateRecipients({
userId,
teamId,
templateId,
recipients: recipientsToCreateFiltered,
});
createdRecipients = [...existingRecipients, ...recipients];
} else {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Invalid envelope type: ${envelope.type}`,
});
}
} }
const fieldsToCreate: FieldToCreate[] = []; const fieldsToCreate: FieldToCreate[] = [];
@ -461,6 +489,7 @@ export const insertFieldsFromPlaceholdersInPDF = async (
fieldsToCreate.push({ fieldsToCreate.push({
...placeholder.fieldAndMeta, ...placeholder.fieldAndMeta,
envelopeItemId,
recipientId, recipientId,
pageNumber: placeholder.page, pageNumber: placeholder.page,
pageX: xPercent, pageX: xPercent,

View File

@ -3,6 +3,7 @@ import { createElement } from 'react';
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import type { Field, Signature } from '@prisma/client'; import type { Field, Signature } from '@prisma/client';
import { import {
DocumentSigningOrder,
DocumentSource, DocumentSource,
DocumentStatus, DocumentStatus,
EnvelopeType, EnvelopeType,
@ -26,7 +27,7 @@ import type { TSignFieldWithTokenMutationSchema } from '@documenso/trpc/server/f
import { getI18nInstance } from '../../client-only/providers/i18n-server'; import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { AppError, AppErrorCode } from '../../errors/app-error'; import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs'; import { DOCUMENT_AUDIT_LOG_TYPE, RECIPIENT_DIFF_TYPE } from '../../types/document-audit-logs';
import type { TRecipientActionAuthTypes } from '../../types/document-auth'; import type { TRecipientActionAuthTypes } from '../../types/document-auth';
import { DocumentAccessAuth, ZRecipientAuthOptionsSchema } from '../../types/document-auth'; import { DocumentAccessAuth, ZRecipientAuthOptionsSchema } from '../../types/document-auth';
import { ZFieldMetaSchema } from '../../types/field-meta'; import { ZFieldMetaSchema } from '../../types/field-meta';
@ -68,6 +69,10 @@ export type CreateDocumentFromDirectTemplateOptions = {
name?: string; name?: string;
email: string; email: string;
}; };
nextSigner?: {
email: string;
name: string;
};
}; };
type CreatedDirectRecipientField = { type CreatedDirectRecipientField = {
@ -92,6 +97,7 @@ export const createDocumentFromDirectTemplate = async ({
directTemplateExternalId, directTemplateExternalId,
signedFieldValues, signedFieldValues,
templateUpdatedAt, templateUpdatedAt,
nextSigner,
requestMetadata, requestMetadata,
user, user,
}: CreateDocumentFromDirectTemplateOptions): Promise<TCreateDocumentFromDirectTemplateResponse> => { }: CreateDocumentFromDirectTemplateOptions): Promise<TCreateDocumentFromDirectTemplateResponse> => {
@ -128,6 +134,17 @@ export const createDocumentFromDirectTemplate = async ({
throw new AppError(AppErrorCode.INVALID_REQUEST, { message: 'Invalid or missing template' }); throw new AppError(AppErrorCode.INVALID_REQUEST, { message: 'Invalid or missing template' });
} }
if (
nextSigner &&
(!directTemplateEnvelope.documentMeta?.allowDictateNextSigner ||
directTemplateEnvelope.documentMeta?.signingOrder !== DocumentSigningOrder.SEQUENTIAL)
) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message:
'You need to enable allowDictateNextSigner and sequential signing to dictate the next signer',
});
}
const directTemplateEnvelopeLegacyId = mapSecondaryIdToTemplateId( const directTemplateEnvelopeLegacyId = mapSecondaryIdToTemplateId(
directTemplateEnvelope.secondaryId, directTemplateEnvelope.secondaryId,
); );
@ -630,6 +647,77 @@ export const createDocumentFromDirectTemplate = async ({
}), }),
]; ];
if (nextSigner) {
const pendingRecipients = await tx.recipient.findMany({
select: {
id: true,
signingOrder: true,
name: true,
email: true,
role: true,
},
where: {
envelopeId: createdEnvelope.id,
signingStatus: {
not: SigningStatus.SIGNED,
},
role: {
not: RecipientRole.CC,
},
},
// Composite sort so our next recipient is always the one with the lowest signing order or id
// if there is a tie.
orderBy: [{ signingOrder: { sort: 'asc', nulls: 'last' } }, { id: 'asc' }],
});
const nextRecipient = pendingRecipients[0];
if (nextRecipient) {
auditLogsToCreate.push(
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
envelopeId: createdEnvelope.id,
user: {
name: user?.name || directRecipientName || '',
email: user?.email || directRecipientEmail,
},
metadata: requestMetadata,
data: {
recipientEmail: nextRecipient.email,
recipientName: nextRecipient.name,
recipientId: nextRecipient.id,
recipientRole: nextRecipient.role,
changes: [
{
type: RECIPIENT_DIFF_TYPE.NAME,
from: nextRecipient.name,
to: nextSigner.name,
},
{
type: RECIPIENT_DIFF_TYPE.EMAIL,
from: nextRecipient.email,
to: nextSigner.email,
},
],
},
}),
);
await tx.recipient.update({
where: { id: nextRecipient.id },
data: {
sendStatus: SendStatus.SENT,
...(nextSigner && documentMeta?.allowDictateNextSigner
? {
name: nextSigner.name,
email: nextSigner.email,
}
: {}),
},
});
}
}
await tx.documentAuditLog.createMany({ await tx.documentAuditLog.createMany({
data: auditLogsToCreate, data: auditLogsToCreate,
}); });

View File

@ -28,6 +28,7 @@ type SeedTemplateOptions = {
title?: string; title?: string;
userId: number; userId: number;
teamId: number; teamId: number;
internalVersion?: 1 | 2;
createTemplateOptions?: Partial<Prisma.EnvelopeUncheckedCreateInput>; createTemplateOptions?: Partial<Prisma.EnvelopeUncheckedCreateInput>;
}; };
@ -167,7 +168,7 @@ export const seedDirectTemplate = async (options: SeedTemplateOptions) => {
data: { data: {
id: prefixedId('envelope'), id: prefixedId('envelope'),
secondaryId: templateId.formattedTemplateId, secondaryId: templateId.formattedTemplateId,
internalVersion: 1, internalVersion: options.internalVersion ?? 1,
type: EnvelopeType.TEMPLATE, type: EnvelopeType.TEMPLATE,
title, title,
envelopeItems: { envelopeItems: {
@ -184,6 +185,7 @@ export const seedDirectTemplate = async (options: SeedTemplateOptions) => {
teamId, teamId,
recipients: { recipients: {
create: { create: {
signingOrder: 1,
email: DIRECT_TEMPLATE_RECIPIENT_EMAIL, email: DIRECT_TEMPLATE_RECIPIENT_EMAIL,
name: DIRECT_TEMPLATE_RECIPIENT_NAME, name: DIRECT_TEMPLATE_RECIPIENT_NAME,
token: Math.random().toString().slice(2, 7), token: Math.random().toString().slice(2, 7),

View File

@ -519,6 +519,7 @@ export const templateRouter = router({
directTemplateExternalId, directTemplateExternalId,
signedFieldValues, signedFieldValues,
templateUpdatedAt, templateUpdatedAt,
nextSigner,
} = input; } = input;
ctx.logger.info({ ctx.logger.info({
@ -541,6 +542,7 @@ export const templateRouter = router({
email: ctx.user.email, email: ctx.user.email,
} }
: undefined, : undefined,
nextSigner,
requestMetadata: ctx.metadata, requestMetadata: ctx.metadata,
}); });
}), }),

View File

@ -90,6 +90,12 @@ export const ZCreateDocumentFromDirectTemplateRequestSchema = z.object({
directTemplateExternalId: z.string().optional(), directTemplateExternalId: z.string().optional(),
signedFieldValues: z.array(ZSignFieldWithTokenMutationSchema), signedFieldValues: z.array(ZSignFieldWithTokenMutationSchema),
templateUpdatedAt: z.date(), templateUpdatedAt: z.date(),
nextSigner: z
.object({
email: z.string().email().max(254),
name: z.string().min(1).max(255),
})
.optional(),
}); });
export const ZCreateDocumentFromTemplateRequestSchema = z.object({ export const ZCreateDocumentFromTemplateRequestSchema = z.object({