diff --git a/apps/remix/app/components/general/document/document-history-sheet.tsx b/apps/remix/app/components/general/document/document-history-sheet.tsx
index 557310ce0..ef73c1c8f 100644
--- a/apps/remix/app/components/general/document/document-history-sheet.tsx
+++ b/apps/remix/app/components/general/document/document-history-sheet.tsx
@@ -351,6 +351,16 @@ export const DocumentHistorySheet = ({
/>
),
)
+ .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_PREFILLED }, ({ data }) => (
+
+ ))
.exhaustive()}
{isUserDetailsVisible && (
diff --git a/apps/remix/app/components/general/document/document-page-view-recipients.tsx b/apps/remix/app/components/general/document/document-page-view-recipients.tsx
index e74cb6e10..854b41e08 100644
--- a/apps/remix/app/components/general/document/document-page-view-recipients.tsx
+++ b/apps/remix/app/components/general/document/document-page-view-recipients.tsx
@@ -11,6 +11,7 @@ import {
MailOpenIcon,
PenIcon,
PlusIcon,
+ UserIcon,
} from 'lucide-react';
import { Link } from 'react-router';
import { match } from 'ts-pattern';
@@ -118,6 +119,12 @@ export const DocumentPageViewRecipients = ({
Viewed
>
))
+ .with(RecipientRole.ASSISTANT, () => (
+ <>
+
+
Assisted
+ >
+ ))
.exhaustive()}
)}
diff --git a/apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx b/apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx
index d5e81589d..c9f3a227c 100644
--- a/apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx
+++ b/apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx
@@ -1,5 +1,5 @@
import { Trans } from '@lingui/react/macro';
-import { DocumentStatus, SigningStatus } from '@prisma/client';
+import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
import { Clock8 } from 'lucide-react';
import { Link, redirect } from 'react-router';
import { getOptionalLoaderContext } from 'server/utils/get-loader-session';
@@ -14,6 +14,7 @@ import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-f
import { getIsRecipientsTurnToSign } from '@documenso/lib/server-only/recipient/get-is-recipient-turn';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
+import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/get-recipients-for-assistant';
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { SigningCard3D } from '@documenso/ui/components/signing-card';
@@ -37,14 +38,14 @@ export async function loader({ params }: Route.LoaderArgs) {
const user = session?.user;
- const [document, fields, recipient, completedFields] = await Promise.all([
+ const [document, recipient, fields, completedFields] = await Promise.all([
getDocumentAndSenderByToken({
token,
userId: user?.id,
requireAccessAuth: false,
}).catch(() => null),
- getFieldsForToken({ token }),
getRecipientByToken({ token }).catch(() => null),
+ getFieldsForToken({ token }),
getCompletedFieldsForToken({ token }),
]);
@@ -57,12 +58,21 @@ export async function loader({ params }: Route.LoaderArgs) {
throw new Response('Not Found', { status: 404 });
}
+ const recipientWithFields = { ...recipient, fields };
+
const isRecipientsTurn = await getIsRecipientsTurnToSign({ token });
if (!isRecipientsTurn) {
throw redirect(`/sign/${token}/waiting`);
}
+ const allRecipients =
+ recipient.role === RecipientRole.ASSISTANT
+ ? await getRecipientsForAssistant({
+ token,
+ })
+ : [];
+
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
documentAuth: document.authOptions,
recipientAuth: recipient.authOptions,
@@ -133,6 +143,8 @@ export async function loader({ params }: Route.LoaderArgs) {
document,
fields,
recipient,
+ recipientWithFields,
+ allRecipients,
completedFields,
recipientSignature,
isRecipientsTurn,
@@ -153,8 +165,16 @@ export default function SigningPage() {
);
}
- const { document, fields, recipient, completedFields, recipientSignature, isRecipientsTurn } =
- data;
+ const {
+ document,
+ fields,
+ recipient,
+ completedFields,
+ recipientSignature,
+ isRecipientsTurn,
+ allRecipients,
+ recipientWithFields,
+ } = data;
if (document.deletedAt) {
return (
@@ -218,11 +238,12 @@ export default function SigningPage() {
user={user}
>
diff --git a/apps/remix/app/routes/embed+/_layout.tsx b/apps/remix/app/routes/embed+/_layout.tsx
index ec2868a9e..f415d3062 100644
--- a/apps/remix/app/routes/embed+/_layout.tsx
+++ b/apps/remix/app/routes/embed+/_layout.tsx
@@ -1,6 +1,7 @@
import { Outlet, isRouteErrorResponse, useRouteError } from 'react-router';
import { EmbedAuthenticationRequired } from '~/components/embed/embed-authentication-required';
+import { EmbedDocumentWaitingForTurn } from '~/components/embed/embed-document-waiting-for-turn';
import { EmbedPaywall } from '~/components/embed/embed-paywall';
import type { Route } from './+types/_layout';
@@ -36,6 +37,10 @@ export function ErrorBoundary() {
if (error.status === 403 && error.data.type === 'embed-paywall') {
return
;
}
+
+ if (error.status === 403 && error.data.type === 'embed-waiting-for-turn') {
+ return
;
+ }
}
return
Not Found
;
diff --git a/apps/remix/app/routes/embed+/direct.$url.tsx b/apps/remix/app/routes/embed+/direct.$url.tsx
index dec8b0584..d5ea213ee 100644
--- a/apps/remix/app/routes/embed+/direct.$url.tsx
+++ b/apps/remix/app/routes/embed+/direct.$url.tsx
@@ -13,6 +13,7 @@ import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { EmbedDirectTemplateClientPage } from '~/components/embed/embed-direct-template-client-page';
import { DocumentSigningAuthProvider } from '~/components/general/document-signing/document-signing-auth-provider';
import { DocumentSigningProvider } from '~/components/general/document-signing/document-signing-provider';
+import { DocumentSigningRecipientProvider } from '~/components/general/document-signing/document-signing-recipient-provider';
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import type { Route } from './+types/direct.$url';
@@ -129,16 +130,18 @@ export default function EmbedDirectTemplatePage() {
recipient={recipient}
user={user}
>
-
+
+
+
);
diff --git a/apps/remix/app/routes/embed+/sign.$url.tsx b/apps/remix/app/routes/embed+/sign.$url.tsx
index ef11dcfef..a4041fa82 100644
--- a/apps/remix/app/routes/embed+/sign.$url.tsx
+++ b/apps/remix/app/routes/embed+/sign.$url.tsx
@@ -1,4 +1,4 @@
-import { DocumentStatus } from '@prisma/client';
+import { DocumentStatus, RecipientRole } from '@prisma/client';
import { data } from 'react-router';
import { getLoaderSession } from 'server/utils/get-loader-session';
import { match } from 'ts-pattern';
@@ -8,7 +8,9 @@ import { isDocumentPlatform } from '@documenso/ee/server-only/util/is-document-p
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
+import { getIsRecipientsTurnToSign } from '@documenso/lib/server-only/recipient/get-is-recipient-turn';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
+import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/get-recipients-for-assistant';
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
@@ -89,6 +91,26 @@ export async function loader({ params }: Route.LoaderArgs) {
);
}
+ const isRecipientsTurnToSign = await getIsRecipientsTurnToSign({ token });
+
+ if (!isRecipientsTurnToSign) {
+ throw data(
+ {
+ type: 'embed-waiting-for-turn',
+ },
+ {
+ status: 403,
+ },
+ );
+ }
+
+ const allRecipients =
+ recipient.role === RecipientRole.ASSISTANT
+ ? await getRecipientsForAssistant({
+ token,
+ })
+ : [];
+
const team = document.teamId
? await getTeamById({ teamId: document.teamId, userId: document.userId }).catch(() => null)
: null;
@@ -99,6 +121,7 @@ export async function loader({ params }: Route.LoaderArgs) {
token,
user,
document,
+ allRecipients,
recipient,
fields,
hidePoweredBy,
@@ -112,6 +135,7 @@ export default function EmbedSignDocumentPage() {
token,
user,
document,
+ allRecipients,
recipient,
fields,
hidePoweredBy,
@@ -140,6 +164,7 @@ export default function EmbedSignDocumentPage() {
isCompleted={document.status === DocumentStatus.COMPLETED}
hidePoweredBy={isPlatformDocument || isEnterpriseDocument || hidePoweredBy}
isPlatformOrEnterprise={isPlatformDocument || isEnterpriseDocument}
+ allRecipients={allRecipients}
/>
diff --git a/packages/app-tests/e2e/document-flow/stepper-component.spec.ts b/packages/app-tests/e2e/document-flow/stepper-component.spec.ts
index 938ec7265..e5a67f09f 100644
--- a/packages/app-tests/e2e/document-flow/stepper-component.spec.ts
+++ b/packages/app-tests/e2e/document-flow/stepper-component.spec.ts
@@ -533,12 +533,19 @@ test('[DOCUMENT_FLOW]: should be able to create and sign a document with 3 recip
if (i > 1) {
await page.getByRole('button', { name: 'Add Signer' }).click();
}
+
await page
- .getByPlaceholder('Email')
+ .getByLabel('Email')
+ .nth(i - 1)
+ .focus();
+
+ await page
+ .getByLabel('Email')
.nth(i - 1)
.fill(`user${i}@example.com`);
+
await page
- .getByPlaceholder('Name')
+ .getByLabel('Name')
.nth(i - 1)
.fill(`User ${i}`);
}
diff --git a/packages/email/template-components/template-document-invite.tsx b/packages/email/template-components/template-document-invite.tsx
index d0c0b0afa..f5d1a5407 100644
--- a/packages/email/template-components/template-document-invite.tsx
+++ b/packages/email/template-components/template-document-invite.tsx
@@ -84,6 +84,9 @@ export const TemplateDocumentInvite = ({
.with(RecipientRole.VIEWER, () =>
Continue by viewing the document.)
.with(RecipientRole.APPROVER, () =>
Continue by approving the document.)
.with(RecipientRole.CC, () => '')
+ .with(RecipientRole.ASSISTANT, () => (
+
Continue by assisting with the document.
+ ))
.exhaustive()}
@@ -104,6 +107,7 @@ export const TemplateDocumentInvite = ({
.with(RecipientRole.VIEWER, () =>
View Document)
.with(RecipientRole.APPROVER, () =>
Approve Document)
.with(RecipientRole.CC, () => '')
+ .with(RecipientRole.ASSISTANT, () =>
Assist Document)
.exhaustive()}
diff --git a/packages/lib/constants/document-audit-logs.ts b/packages/lib/constants/document-audit-logs.ts
index 8ae654977..9b91d2cb9 100644
--- a/packages/lib/constants/document-audit-logs.ts
+++ b/packages/lib/constants/document-audit-logs.ts
@@ -10,6 +10,9 @@ export const DOCUMENT_AUDIT_LOG_EMAIL_FORMAT = {
[DOCUMENT_EMAIL_TYPE.APPROVE_REQUEST]: {
description: 'Approval request',
},
+ [DOCUMENT_EMAIL_TYPE.ASSISTING_REQUEST]: {
+ description: 'Assisting request',
+ },
[DOCUMENT_EMAIL_TYPE.CC]: {
description: 'CC',
},
diff --git a/packages/lib/constants/recipient-roles.ts b/packages/lib/constants/recipient-roles.ts
index 2e30ec92b..f95390968 100644
--- a/packages/lib/constants/recipient-roles.ts
+++ b/packages/lib/constants/recipient-roles.ts
@@ -31,12 +31,26 @@ export const RECIPIENT_ROLES_DESCRIPTION = {
roleName: msg`Viewer`,
roleNamePlural: msg`Viewers`,
},
+ [RecipientRole.ASSISTANT]: {
+ actionVerb: msg`Assist`,
+ actioned: msg`Assisted`,
+ progressiveVerb: msg`Assisting`,
+ roleName: msg`Assistant`,
+ roleNamePlural: msg`Assistants`,
+ },
} satisfies Record
;
+export const RECIPIENT_ROLE_TO_DISPLAY_TYPE = {
+ [RecipientRole.SIGNER]: `SIGNING_REQUEST`,
+ [RecipientRole.VIEWER]: `VIEW_REQUEST`,
+ [RecipientRole.APPROVER]: `APPROVE_REQUEST`,
+} as const;
+
export const RECIPIENT_ROLE_TO_EMAIL_TYPE = {
[RecipientRole.SIGNER]: `SIGNING_REQUEST`,
[RecipientRole.VIEWER]: `VIEW_REQUEST`,
[RecipientRole.APPROVER]: `APPROVE_REQUEST`,
+ [RecipientRole.ASSISTANT]: `ASSISTING_REQUEST`,
} as const;
export const RECIPIENT_ROLE_SIGNING_REASONS = {
@@ -44,4 +58,5 @@ export const RECIPIENT_ROLE_SIGNING_REASONS = {
[RecipientRole.APPROVER]: msg`I am an approver of this document`,
[RecipientRole.CC]: msg`I am required to receive a copy of this document`,
[RecipientRole.VIEWER]: msg`I am a viewer of this document`,
+ [RecipientRole.ASSISTANT]: msg`I am an assistant of this document`,
} satisfies Record;
diff --git a/packages/lib/server-only/field/get-fields-for-token.ts b/packages/lib/server-only/field/get-fields-for-token.ts
index 635773f8f..6abb07281 100644
--- a/packages/lib/server-only/field/get-fields-for-token.ts
+++ b/packages/lib/server-only/field/get-fields-for-token.ts
@@ -1,15 +1,55 @@
import { prisma } from '@documenso/prisma';
+import { FieldType, RecipientRole, SigningStatus } from '@documenso/prisma/client';
export type GetFieldsForTokenOptions = {
token: string;
};
export const getFieldsForToken = async ({ token }: GetFieldsForTokenOptions) => {
+ if (!token) {
+ throw new Error('Missing token');
+ }
+
+ const recipient = await prisma.recipient.findFirst({
+ where: { token },
+ });
+
+ if (!recipient) {
+ return [];
+ }
+
+ if (recipient.role === RecipientRole.ASSISTANT) {
+ return await prisma.field.findMany({
+ where: {
+ OR: [
+ {
+ type: {
+ not: FieldType.SIGNATURE,
+ },
+ recipient: {
+ signingStatus: {
+ not: SigningStatus.SIGNED,
+ },
+ signingOrder: {
+ gte: recipient.signingOrder ?? 0,
+ },
+ },
+ documentId: recipient.documentId,
+ },
+ {
+ recipientId: recipient.id,
+ },
+ ],
+ },
+ include: {
+ signature: true,
+ },
+ });
+ }
+
return await prisma.field.findMany({
where: {
- recipient: {
- token,
- },
+ recipientId: recipient.id,
},
include: {
signature: true,
diff --git a/packages/lib/server-only/field/remove-signed-field-with-token.ts b/packages/lib/server-only/field/remove-signed-field-with-token.ts
index 39ea34e80..e95a8048d 100644
--- a/packages/lib/server-only/field/remove-signed-field-with-token.ts
+++ b/packages/lib/server-only/field/remove-signed-field-with-token.ts
@@ -1,4 +1,4 @@
-import { DocumentStatus, SigningStatus } from '@prisma/client';
+import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
@@ -16,11 +16,28 @@ export const removeSignedFieldWithToken = async ({
fieldId,
requestMetadata,
}: RemovedSignedFieldWithTokenOptions) => {
+ const recipient = await prisma.recipient.findFirstOrThrow({
+ where: {
+ token,
+ },
+ });
+
const field = await prisma.field.findFirstOrThrow({
where: {
id: fieldId,
recipient: {
- token,
+ ...(recipient.role !== RecipientRole.ASSISTANT
+ ? {
+ id: recipient.id,
+ }
+ : {
+ signingOrder: {
+ gte: recipient.signingOrder ?? 0,
+ },
+ signingStatus: {
+ not: SigningStatus.SIGNED,
+ },
+ }),
},
},
include: {
@@ -29,7 +46,7 @@ export const removeSignedFieldWithToken = async ({
},
});
- const { document, recipient } = field;
+ const { document } = field;
if (!document) {
throw new Error(`Document not found for field ${field.id}`);
@@ -39,7 +56,10 @@ export const removeSignedFieldWithToken = async ({
throw new Error(`Document ${document.id} must be pending`);
}
- if (recipient?.signingStatus === SigningStatus.SIGNED) {
+ if (
+ recipient?.signingStatus === SigningStatus.SIGNED ||
+ field.recipient.signingStatus === SigningStatus.SIGNED
+ ) {
throw new Error(`Recipient ${recipient.id} has already signed`);
}
@@ -65,20 +85,22 @@ export const removeSignedFieldWithToken = async ({
},
});
- await tx.documentAuditLog.create({
- data: createDocumentAuditLogData({
- type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED,
- documentId: document.id,
- user: {
- name: recipient?.name,
- email: recipient?.email,
- },
- requestMetadata,
- data: {
- field: field.type,
- fieldId: field.secondaryId,
- },
- }),
- });
+ if (recipient.role !== RecipientRole.ASSISTANT) {
+ await tx.documentAuditLog.create({
+ data: createDocumentAuditLogData({
+ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED,
+ documentId: document.id,
+ user: {
+ name: recipient.name,
+ email: recipient.email,
+ },
+ requestMetadata,
+ data: {
+ field: field.type,
+ fieldId: field.secondaryId,
+ },
+ }),
+ });
+ }
});
};
diff --git a/packages/lib/server-only/field/sign-field-with-token.ts b/packages/lib/server-only/field/sign-field-with-token.ts
index 48dd6d1f3..22933ba75 100644
--- a/packages/lib/server-only/field/sign-field-with-token.ts
+++ b/packages/lib/server-only/field/sign-field-with-token.ts
@@ -1,4 +1,4 @@
-import { DocumentStatus, FieldType, SigningStatus } from '@prisma/client';
+import { DocumentStatus, FieldType, RecipientRole, SigningStatus } from '@prisma/client';
import { DateTime } from 'luxon';
import { match } from 'ts-pattern';
@@ -54,20 +54,41 @@ export const signFieldWithToken = async ({
authOptions,
requestMetadata,
}: SignFieldWithTokenOptions) => {
+ const recipient = await prisma.recipient.findFirstOrThrow({
+ where: {
+ token,
+ },
+ });
+
const field = await prisma.field.findFirstOrThrow({
where: {
id: fieldId,
recipient: {
- token,
+ ...(recipient.role !== RecipientRole.ASSISTANT
+ ? {
+ id: recipient.id,
+ }
+ : {
+ signingStatus: {
+ not: SigningStatus.SIGNED,
+ },
+ signingOrder: {
+ gte: recipient.signingOrder ?? 0,
+ },
+ }),
},
},
include: {
- document: true,
+ document: {
+ include: {
+ recipients: true,
+ },
+ },
recipient: true,
},
});
- const { document, recipient } = field;
+ const { document } = field;
if (!document) {
throw new Error(`Document not found for field ${field.id}`);
@@ -85,7 +106,10 @@ export const signFieldWithToken = async ({
throw new Error(`Document ${document.id} must be pending for signing`);
}
- if (recipient?.signingStatus === SigningStatus.SIGNED) {
+ if (
+ recipient.signingStatus === SigningStatus.SIGNED ||
+ field.recipient.signingStatus === SigningStatus.SIGNED
+ ) {
throw new Error(`Recipient ${recipient.id} has already signed`);
}
@@ -181,6 +205,8 @@ export const signFieldWithToken = async ({
throw new Error('Typed signatures are not allowed. Please draw your signature');
}
+ const assistant = recipient.role === RecipientRole.ASSISTANT ? recipient : undefined;
+
return await prisma.$transaction(async (tx) => {
const updatedField = await tx.field.update({
where: {
@@ -217,11 +243,14 @@ export const signFieldWithToken = async ({
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
- type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED,
+ type:
+ assistant && field.recipientId !== assistant.id
+ ? DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_PREFILLED
+ : DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED,
documentId: document.id,
user: {
- email: recipient.email,
- name: recipient.name,
+ email: assistant?.email ?? recipient.email,
+ name: assistant?.name ?? recipient.name,
},
requestMetadata,
data: {
diff --git a/packages/lib/server-only/recipient/get-recipient-by-token.ts b/packages/lib/server-only/recipient/get-recipient-by-token.ts
index d12151b41..d24a08603 100644
--- a/packages/lib/server-only/recipient/get-recipient-by-token.ts
+++ b/packages/lib/server-only/recipient/get-recipient-by-token.ts
@@ -9,5 +9,8 @@ export const getRecipientByToken = async ({ token }: GetRecipientByTokenOptions)
where: {
token,
},
+ include: {
+ fields: true,
+ },
});
};
diff --git a/packages/lib/server-only/recipient/get-recipients-for-assistant.ts b/packages/lib/server-only/recipient/get-recipients-for-assistant.ts
new file mode 100644
index 000000000..6c15af639
--- /dev/null
+++ b/packages/lib/server-only/recipient/get-recipients-for-assistant.ts
@@ -0,0 +1,57 @@
+import { prisma } from '@documenso/prisma';
+import { FieldType } from '@documenso/prisma/client';
+
+import { AppError, AppErrorCode } from '../../errors/app-error';
+
+export interface GetRecipientsForAssistantOptions {
+ token: string;
+}
+
+export const getRecipientsForAssistant = async ({ token }: GetRecipientsForAssistantOptions) => {
+ const assistant = await prisma.recipient.findFirst({
+ where: {
+ token,
+ },
+ });
+
+ if (!assistant) {
+ throw new AppError(AppErrorCode.NOT_FOUND, {
+ message: 'Assistant not found',
+ });
+ }
+
+ let recipients = await prisma.recipient.findMany({
+ where: {
+ documentId: assistant.documentId,
+ signingOrder: {
+ gte: assistant.signingOrder ?? 0,
+ },
+ },
+ include: {
+ fields: {
+ where: {
+ OR: [
+ {
+ recipientId: assistant.id,
+ },
+ {
+ type: {
+ not: FieldType.SIGNATURE,
+ },
+ documentId: assistant.documentId,
+ },
+ ],
+ },
+ },
+ },
+ });
+
+ // Omit the token for recipients other than the assistant so
+ // it doesn't get sent to the client.
+ recipients = recipients.map((recipient) => ({
+ ...recipient,
+ token: recipient.id === assistant.id ? token : '',
+ }));
+
+ return recipients;
+};
diff --git a/packages/lib/types/document-audit-logs.ts b/packages/lib/types/document-audit-logs.ts
index 3d6f1d858..cb7873834 100644
--- a/packages/lib/types/document-audit-logs.ts
+++ b/packages/lib/types/document-audit-logs.ts
@@ -27,6 +27,7 @@ export const ZDocumentAuditLogTypeSchema = z.enum([
'DOCUMENT_DELETED', // When the document is soft deleted.
'DOCUMENT_FIELD_INSERTED', // When a field is inserted (signed/approved/etc) by a recipient.
'DOCUMENT_FIELD_UNINSERTED', // When a field is uninserted by a recipient.
+ 'DOCUMENT_FIELD_PREFILLED', // When a field is prefilled by an assistant.
'DOCUMENT_VISIBILITY_UPDATED', // When the document visibility scope is updated
'DOCUMENT_GLOBAL_AUTH_ACCESS_UPDATED', // When the global access authentication is updated.
'DOCUMENT_GLOBAL_AUTH_ACTION_UPDATED', // When the global action authentication is updated.
@@ -44,6 +45,7 @@ export const ZDocumentAuditLogEmailTypeSchema = z.enum([
'SIGNING_REQUEST',
'VIEW_REQUEST',
'APPROVE_REQUEST',
+ 'ASSISTING_REQUEST',
'CC',
'DOCUMENT_COMPLETED',
]);
@@ -312,6 +314,83 @@ export const ZDocumentAuditLogEventDocumentFieldUninsertedSchema = z.object({
}),
});
+/**
+ * Event: Document field prefilled by assistant.
+ */
+export const ZDocumentAuditLogEventDocumentFieldPrefilledSchema = z.object({
+ type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_PREFILLED),
+ data: ZBaseRecipientDataSchema.extend({
+ fieldId: z.string(),
+
+ // Organised into union to allow us to extend each field if required.
+ field: z.union([
+ z.object({
+ type: z.literal(FieldType.INITIALS),
+ data: z.string(),
+ }),
+ z.object({
+ type: z.literal(FieldType.EMAIL),
+ data: z.string(),
+ }),
+ z.object({
+ type: z.literal(FieldType.DATE),
+ data: z.string(),
+ }),
+ z.object({
+ type: z.literal(FieldType.NAME),
+ data: z.string(),
+ }),
+ z.object({
+ type: z.literal(FieldType.TEXT),
+ data: z.string(),
+ }),
+ z.object({
+ type: z.union([z.literal(FieldType.SIGNATURE), z.literal(FieldType.FREE_SIGNATURE)]),
+ data: z.string(),
+ }),
+ z.object({
+ type: z.literal(FieldType.RADIO),
+ data: z.string(),
+ }),
+ z.object({
+ type: z.literal(FieldType.CHECKBOX),
+ data: z.string(),
+ }),
+ z.object({
+ type: z.literal(FieldType.DROPDOWN),
+ data: z.string(),
+ }),
+ z.object({
+ type: z.literal(FieldType.NUMBER),
+ data: z.string(),
+ }),
+ ]),
+ fieldSecurity: z.preprocess(
+ (input) => {
+ const legacyNoneSecurityType = JSON.stringify({
+ type: 'NONE',
+ });
+
+ // Replace legacy 'NONE' field security type with undefined.
+ if (
+ typeof input === 'object' &&
+ input !== null &&
+ JSON.stringify(input) === legacyNoneSecurityType
+ ) {
+ return undefined;
+ }
+
+ return input;
+ },
+ z
+ .object({
+ type: ZRecipientActionAuthTypesSchema,
+ })
+ .optional(),
+ ),
+ }),
+});
+
export const ZDocumentAuditLogEventDocumentVisibilitySchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_VISIBILITY_UPDATED),
data: ZGenericFromToSchema,
@@ -492,6 +571,7 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
ZDocumentAuditLogEventDocumentMovedToTeamSchema,
ZDocumentAuditLogEventDocumentFieldInsertedSchema,
ZDocumentAuditLogEventDocumentFieldUninsertedSchema,
+ ZDocumentAuditLogEventDocumentFieldPrefilledSchema,
ZDocumentAuditLogEventDocumentVisibilitySchema,
ZDocumentAuditLogEventDocumentGlobalAuthAccessUpdatedSchema,
ZDocumentAuditLogEventDocumentGlobalAuthActionUpdatedSchema,
diff --git a/packages/lib/utils/document-audit-logs.ts b/packages/lib/utils/document-audit-logs.ts
index 27278abd1..bc564370b 100644
--- a/packages/lib/utils/document-audit-logs.ts
+++ b/packages/lib/utils/document-audit-logs.ts
@@ -313,6 +313,10 @@ export const formatDocumentAuditLogAction = (
anonymous: msg`Field unsigned`,
identified: msg`${prefix} unsigned a field`,
}))
+ .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_PREFILLED }, () => ({
+ anonymous: msg`Field prefilled by assistant`,
+ identified: msg`${prefix} prefilled a field`,
+ }))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_VISIBILITY_UPDATED }, () => ({
anonymous: msg`Document visibility updated`,
identified: msg`${prefix} updated the document visibility`,
diff --git a/packages/prisma/migrations/20250108133544_add_assistant_recipient_role/migration.sql b/packages/prisma/migrations/20250108133544_add_assistant_recipient_role/migration.sql
new file mode 100644
index 000000000..b5eb3e491
--- /dev/null
+++ b/packages/prisma/migrations/20250108133544_add_assistant_recipient_role/migration.sql
@@ -0,0 +1,2 @@
+-- AlterEnum
+ALTER TYPE "RecipientRole" ADD VALUE 'ASSISTANT';
diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma
index c80c0ad3e..4c012346a 100644
--- a/packages/prisma/schema.prisma
+++ b/packages/prisma/schema.prisma
@@ -425,6 +425,7 @@ enum RecipientRole {
SIGNER
VIEWER
APPROVER
+ ASSISTANT
}
/// @zod.import(["import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';"])
diff --git a/packages/prisma/types/recipient-with-fields.ts b/packages/prisma/types/recipient-with-fields.ts
new file mode 100644
index 000000000..ed4314897
--- /dev/null
+++ b/packages/prisma/types/recipient-with-fields.ts
@@ -0,0 +1,5 @@
+import type { Field, Recipient } from '@documenso/prisma/client';
+
+export type RecipientWithFields = Recipient & {
+ fields: Field[];
+};
diff --git a/packages/ui/components/recipient/recipient-role-select.tsx b/packages/ui/components/recipient/recipient-role-select.tsx
index da0b0c097..0114a394e 100644
--- a/packages/ui/components/recipient/recipient-role-select.tsx
+++ b/packages/ui/components/recipient/recipient-role-select.tsx
@@ -9,12 +9,15 @@ import { ROLE_ICONS } from '@documenso/ui/primitives/recipient-role-icons';
import { Select, SelectContent, SelectItem, SelectTrigger } from '@documenso/ui/primitives/select';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
+import { cn } from '../../lib/utils';
+
export type RecipientRoleSelectProps = SelectProps & {
hideCCRecipients?: boolean;
+ isAssistantEnabled?: boolean;
};
export const RecipientRoleSelect = forwardRef(
- ({ hideCCRecipients, ...props }, ref) => (
+ ({ hideCCRecipients, isAssistantEnabled = true, ...props }, ref) => (
),
diff --git a/packages/ui/primitives/document-flow/add-fields.tsx b/packages/ui/primitives/document-flow/add-fields.tsx
index 49653c516..39139e29b 100644
--- a/packages/ui/primitives/document-flow/add-fields.tsx
+++ b/packages/ui/primitives/document-flow/add-fields.tsx
@@ -498,7 +498,15 @@ export const AddFieldsFormPartial = ({
}, []);
useEffect(() => {
- setSelectedSigner(recipients.find((r) => r.sendStatus !== SendStatus.SENT) ?? recipients[0]);
+ const recipientsByRoleToDisplay = recipients.filter(
+ (recipient) =>
+ recipient.role !== RecipientRole.CC && recipient.role !== RecipientRole.ASSISTANT,
+ );
+
+ setSelectedSigner(
+ recipientsByRoleToDisplay.find((r) => r.sendStatus !== SendStatus.SENT) ??
+ recipientsByRoleToDisplay[0],
+ );
}, [recipients]);
const recipientsByRole = useMemo(() => {
@@ -507,6 +515,7 @@ export const AddFieldsFormPartial = ({
VIEWER: [],
SIGNER: [],
APPROVER: [],
+ ASSISTANT: [],
};
recipients.forEach((recipient) => {
@@ -519,7 +528,12 @@ export const AddFieldsFormPartial = ({
const recipientsByRoleToDisplay = useMemo(() => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return (Object.entries(recipientsByRole) as [RecipientRole, Recipient[]][])
- .filter(([role]) => role !== RecipientRole.CC && role !== RecipientRole.VIEWER)
+ .filter(
+ ([role]) =>
+ role !== RecipientRole.CC &&
+ role !== RecipientRole.VIEWER &&
+ role !== RecipientRole.ASSISTANT,
+ )
.map(
([role, roleRecipients]) =>
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
@@ -671,9 +685,7 @@ export const AddFieldsFormPartial = ({
)}
{!selectedSigner?.email && (
-
- {selectedSigner?.email}
-
+ {selectedSigner?.email}
)}
diff --git a/packages/ui/primitives/document-flow/add-signers.tsx b/packages/ui/primitives/document-flow/add-signers.tsx
index c277094c9..ce86cff42 100644
--- a/packages/ui/primitives/document-flow/add-signers.tsx
+++ b/packages/ui/primitives/document-flow/add-signers.tsx
@@ -40,6 +40,7 @@ import {
DocumentFlowFormContainerStep,
} from './document-flow-root';
import { ShowFieldItem } from './show-field-item';
+import { SigningOrderConfirmation } from './signing-order-confirmation';
import type { DocumentFlowStep } from './types';
export type AddSignersFormProps = {
@@ -120,6 +121,7 @@ export const AddSignersFormPartial = ({
}, [recipients, form]);
const [showAdvancedSettings, setShowAdvancedSettings] = useState(alwaysShowAdvancedSettings);
+ const [showSigningOrderConfirmation, setShowSigningOrderConfirmation] = useState(false);
const {
setValue,
@@ -131,6 +133,10 @@ export const AddSignersFormPartial = ({
const watchedSigners = watch('signers');
const isSigningOrderSequential = watch('signingOrder') === DocumentSigningOrder.SEQUENTIAL;
+ const hasAssistantRole = useMemo(() => {
+ return watchedSigners.some((signer) => signer.role === RecipientRole.ASSISTANT);
+ }, [watchedSigners]);
+
const normalizeSigningOrders = (signers: typeof watchedSigners) => {
return signers
.sort((a, b) => (a.signingOrder ?? 0) - (b.signingOrder ?? 0))
@@ -230,6 +236,7 @@ export const AddSignersFormPartial = ({
const items = Array.from(watchedSigners);
const [reorderedSigner] = items.splice(result.source.index, 1);
+ // Find next valid position
let insertIndex = result.destination.index;
while (insertIndex < items.length && !canRecipientBeModified(items[insertIndex].nativeId)) {
insertIndex++;
@@ -237,126 +244,116 @@ export const AddSignersFormPartial = ({
items.splice(insertIndex, 0, reorderedSigner);
- const updatedSigners = items.map((item, index) => ({
- ...item,
- signingOrder: !canRecipientBeModified(item.nativeId) ? item.signingOrder : index + 1,
+ const updatedSigners = items.map((signer, index) => ({
+ ...signer,
+ signingOrder: !canRecipientBeModified(signer.nativeId) ? signer.signingOrder : index + 1,
}));
- updatedSigners.forEach((item, index) => {
- const keys: (keyof typeof item)[] = [
- 'formId',
- 'nativeId',
- 'email',
- 'name',
- 'role',
- 'signingOrder',
- 'actionAuth',
- ];
- keys.forEach((key) => {
- form.setValue(`signers.${index}.${key}` as const, item[key]);
- });
- });
+ form.setValue('signers', updatedSigners);
- const currentLength = form.getValues('signers').length;
- if (currentLength > updatedSigners.length) {
- for (let i = updatedSigners.length; i < currentLength; i++) {
- form.unregister(`signers.${i}`);
- }
+ const lastSigner = updatedSigners[updatedSigners.length - 1];
+ if (lastSigner.role === RecipientRole.ASSISTANT) {
+ toast({
+ title: _(msg`Warning: Assistant as last signer`),
+ description: _(
+ msg`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`,
+ ),
+ });
}
await form.trigger('signers');
},
- [form, canRecipientBeModified, watchedSigners],
+ [form, canRecipientBeModified, watchedSigners, toast],
);
- const triggerDragAndDrop = useCallback(
- (fromIndex: number, toIndex: number) => {
- if (!$sensorApi.current) {
+ const handleRoleChange = useCallback(
+ (index: number, role: RecipientRole) => {
+ const currentSigners = form.getValues('signers');
+ const signingOrder = form.getValues('signingOrder');
+
+ // Handle parallel to sequential conversion for assistants
+ if (role === RecipientRole.ASSISTANT && signingOrder === DocumentSigningOrder.PARALLEL) {
+ form.setValue('signingOrder', DocumentSigningOrder.SEQUENTIAL);
+ toast({
+ title: _(msg`Signing order is enabled.`),
+ description: _(msg`You cannot add assistants when signing order is disabled.`),
+ variant: 'destructive',
+ });
return;
}
- const draggableId = signers[fromIndex].id;
+ const updatedSigners = currentSigners.map((signer, idx) => ({
+ ...signer,
+ role: idx === index ? role : signer.role,
+ signingOrder: !canRecipientBeModified(signer.nativeId) ? signer.signingOrder : idx + 1,
+ }));
- const preDrag = $sensorApi.current.tryGetLock(draggableId);
+ form.setValue('signers', updatedSigners);
- if (!preDrag) {
- return;
+ if (role === RecipientRole.ASSISTANT && index === updatedSigners.length - 1) {
+ toast({
+ title: _(msg`Warning: Assistant as last signer`),
+ description: _(
+ msg`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`,
+ ),
+ });
}
-
- const drag = preDrag.snapLift();
-
- setTimeout(() => {
- // Move directly to the target index
- if (fromIndex < toIndex) {
- for (let i = fromIndex; i < toIndex; i++) {
- drag.moveDown();
- }
- } else {
- for (let i = fromIndex; i > toIndex; i--) {
- drag.moveUp();
- }
- }
-
- setTimeout(() => {
- drag.drop();
- }, 500);
- }, 0);
},
- [signers],
- );
-
- const updateSigningOrders = useCallback(
- (newIndex: number, oldIndex: number) => {
- const updatedSigners = form.getValues('signers').map((signer, index) => {
- if (index === oldIndex) {
- return { ...signer, signingOrder: newIndex + 1 };
- } else if (index >= newIndex && index < oldIndex) {
- return {
- ...signer,
- signingOrder: !canRecipientBeModified(signer.nativeId)
- ? signer.signingOrder
- : (signer.signingOrder ?? index + 1) + 1,
- };
- } else if (index <= newIndex && index > oldIndex) {
- return {
- ...signer,
- signingOrder: !canRecipientBeModified(signer.nativeId)
- ? signer.signingOrder
- : Math.max(1, (signer.signingOrder ?? index + 1) - 1),
- };
- }
- return signer;
- });
-
- updatedSigners.forEach((signer, index) => {
- form.setValue(`signers.${index}.signingOrder`, signer.signingOrder);
- });
- },
- [form, canRecipientBeModified],
+ [form, toast, canRecipientBeModified],
);
const handleSigningOrderChange = useCallback(
(index: number, newOrderString: string) => {
- const newOrder = parseInt(newOrderString, 10);
-
- if (!newOrderString.trim()) {
+ const trimmedOrderString = newOrderString.trim();
+ if (!trimmedOrderString) {
return;
}
- if (Number.isNaN(newOrder)) {
- form.setValue(`signers.${index}.signingOrder`, index + 1);
+ const newOrder = Number(trimmedOrderString);
+ if (!Number.isInteger(newOrder) || newOrder < 1) {
return;
}
- const newIndex = newOrder - 1;
- if (index !== newIndex) {
- updateSigningOrders(newIndex, index);
- triggerDragAndDrop(index, newIndex);
+ const currentSigners = form.getValues('signers');
+ const signer = currentSigners[index];
+
+ // Remove signer from current position and insert at new position
+ const remainingSigners = currentSigners.filter((_, idx) => idx !== index);
+ const newPosition = Math.min(Math.max(0, newOrder - 1), currentSigners.length - 1);
+ remainingSigners.splice(newPosition, 0, signer);
+
+ const updatedSigners = remainingSigners.map((s, idx) => ({
+ ...s,
+ signingOrder: !canRecipientBeModified(s.nativeId) ? s.signingOrder : idx + 1,
+ }));
+
+ form.setValue('signers', updatedSigners);
+
+ if (signer.role === RecipientRole.ASSISTANT && newPosition === remainingSigners.length - 1) {
+ toast({
+ title: _(msg`Warning: Assistant as last signer`),
+ description: _(
+ msg`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`,
+ ),
+ });
}
},
- [form, triggerDragAndDrop, updateSigningOrders],
+ [form, canRecipientBeModified, toast],
);
+ const handleSigningOrderDisable = useCallback(() => {
+ setShowSigningOrderConfirmation(false);
+
+ const currentSigners = form.getValues('signers');
+ const updatedSigners = currentSigners.map((signer) => ({
+ ...signer,
+ role: signer.role === RecipientRole.ASSISTANT ? RecipientRole.SIGNER : signer.role,
+ }));
+
+ form.setValue('signers', updatedSigners);
+ form.setValue('signingOrder', DocumentSigningOrder.PARALLEL);
+ }, [form]);
+
return (
<>
+ onCheckedChange={(checked) => {
+ if (!checked && hasAssistantRole) {
+ setShowSigningOrderConfirmation(true);
+ return;
+ }
+
field.onChange(
checked ? DocumentSigningOrder.SEQUENTIAL : DocumentSigningOrder.PARALLEL,
- )
- }
+ );
+ }}
disabled={isSubmitting || hasDocumentBeenSent}
/>
@@ -610,7 +612,11 @@ export const AddSignersFormPartial = ({
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
+ handleRoleChange(index, value as RecipientRole)
+ }
disabled={
snapshot.isDragging ||
isSubmitting ||
@@ -707,6 +713,12 @@ export const AddSignersFormPartial = ({
)}
+
+
diff --git a/packages/ui/primitives/document-flow/signing-order-confirmation.tsx b/packages/ui/primitives/document-flow/signing-order-confirmation.tsx
new file mode 100644
index 000000000..e127ec484
--- /dev/null
+++ b/packages/ui/primitives/document-flow/signing-order-confirmation.tsx
@@ -0,0 +1,40 @@
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from '@documenso/ui/primitives/alert-dialog';
+
+export type SigningOrderConfirmationProps = {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ onConfirm: () => void;
+};
+
+export function SigningOrderConfirmation({
+ open,
+ onOpenChange,
+ onConfirm,
+}: SigningOrderConfirmationProps) {
+ return (
+
+
+
+ Warning
+
+ You have an assistant role on the signers list, removing the signing order will change
+ the assistant role to signer.
+
+
+
+ Cancel
+ Proceed
+
+
+
+ );
+}
diff --git a/packages/ui/primitives/radio-group.tsx b/packages/ui/primitives/radio-group.tsx
index 931d3aa40..cafb841e5 100644
--- a/packages/ui/primitives/radio-group.tsx
+++ b/packages/ui/primitives/radio-group.tsx
@@ -17,18 +17,18 @@ RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
const RadioGroupItem = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
->(({ className, children: _children, ...props }, ref) => {
+>(({ className, ...props }, ref) => {
return (
-
+
);
diff --git a/packages/ui/primitives/recipient-role-icons.tsx b/packages/ui/primitives/recipient-role-icons.tsx
index 4a139e59f..ab855e41f 100644
--- a/packages/ui/primitives/recipient-role-icons.tsx
+++ b/packages/ui/primitives/recipient-role-icons.tsx
@@ -1,9 +1,10 @@
import type { RecipientRole } from '@prisma/client';
-import { BadgeCheck, Copy, Eye, PencilLine } from 'lucide-react';
+import { BadgeCheck, Copy, Eye, PencilLine, User } from 'lucide-react';
export const ROLE_ICONS: Record = {
SIGNER: ,
APPROVER: ,
CC: ,
VIEWER: ,
+ ASSISTANT: ,
};
diff --git a/packages/ui/primitives/template-flow/add-template-fields.tsx b/packages/ui/primitives/template-flow/add-template-fields.tsx
index 5d1190e3a..88ae949f2 100644
--- a/packages/ui/primitives/template-flow/add-template-fields.tsx
+++ b/packages/ui/primitives/template-flow/add-template-fields.tsx
@@ -4,7 +4,7 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { Field, Recipient } from '@prisma/client';
-import { FieldType, RecipientRole } from '@prisma/client';
+import { FieldType, RecipientRole, SendStatus } from '@prisma/client';
import {
CalendarDays,
CheckSquare,
@@ -428,6 +428,7 @@ export const AddTemplateFieldsFormPartial = ({
VIEWER: [],
SIGNER: [],
APPROVER: [],
+ ASSISTANT: [],
};
recipients.forEach((recipient) => {
@@ -437,10 +438,25 @@ export const AddTemplateFieldsFormPartial = ({
return recipientsByRole;
}, [recipients]);
+ useEffect(() => {
+ const recipientsByRoleToDisplay = recipients.filter(
+ (recipient) =>
+ recipient.role !== RecipientRole.CC && recipient.role !== RecipientRole.ASSISTANT,
+ );
+
+ setSelectedSigner(
+ recipientsByRoleToDisplay.find((r) => r.sendStatus !== SendStatus.SENT) ??
+ recipientsByRoleToDisplay[0],
+ );
+ }, [recipients]);
+
const recipientsByRoleToDisplay = useMemo(() => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return (Object.entries(recipientsByRole) as [RecipientRole, Recipient[]][]).filter(
- ([role]) => role !== RecipientRole.CC && role !== RecipientRole.VIEWER,
+ ([role]) =>
+ role !== RecipientRole.CC &&
+ role !== RecipientRole.VIEWER &&
+ role !== RecipientRole.ASSISTANT,
);
}, [recipientsByRole]);
diff --git a/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx b/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx
index 175984c14..b3709c587 100644
--- a/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx
+++ b/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx
@@ -23,6 +23,7 @@ import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
import { Input } from '@documenso/ui/primitives/input';
+import { toast } from '@documenso/ui/primitives/use-toast';
import { Checkbox } from '../checkbox';
import {
@@ -33,6 +34,7 @@ import {
DocumentFlowFormContainerStep,
} from '../document-flow/document-flow-root';
import { ShowFieldItem } from '../document-flow/show-field-item';
+import { SigningOrderConfirmation } from '../document-flow/signing-order-confirmation';
import type { DocumentFlowStep } from '../document-flow/types';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '../form/form';
import { useStep } from '../stepper';
@@ -205,41 +207,30 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
const items = Array.from(watchedSigners);
const [reorderedSigner] = items.splice(result.source.index, 1);
-
const insertIndex = result.destination.index;
items.splice(insertIndex, 0, reorderedSigner);
- const updatedSigners = items.map((item, index) => ({
- ...item,
+ const updatedSigners = items.map((signer, index) => ({
+ ...signer,
signingOrder: index + 1,
}));
- updatedSigners.forEach((item, index) => {
- const keys: (keyof typeof item)[] = [
- 'formId',
- 'nativeId',
- 'email',
- 'name',
- 'role',
- 'signingOrder',
- 'actionAuth',
- ];
- keys.forEach((key) => {
- form.setValue(`signers.${index}.${key}` as const, item[key]);
- });
- });
+ form.setValue('signers', updatedSigners);
- const currentLength = form.getValues('signers').length;
- if (currentLength > updatedSigners.length) {
- for (let i = updatedSigners.length; i < currentLength; i++) {
- form.unregister(`signers.${i}`);
- }
+ const lastSigner = updatedSigners[updatedSigners.length - 1];
+ if (lastSigner.role === RecipientRole.ASSISTANT) {
+ toast({
+ title: _(msg`Warning: Assistant as last signer`),
+ description: _(
+ msg`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`,
+ ),
+ });
}
await form.trigger('signers');
},
- [form, watchedSigners],
+ [form, watchedSigners, toast],
);
const triggerDragAndDrop = useCallback(
@@ -300,26 +291,94 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
const handleSigningOrderChange = useCallback(
(index: number, newOrderString: string) => {
- const newOrder = parseInt(newOrderString, 10);
-
- if (!newOrderString.trim()) {
+ const trimmedOrderString = newOrderString.trim();
+ if (!trimmedOrderString) {
return;
}
- if (Number.isNaN(newOrder)) {
- form.setValue(`signers.${index}.signingOrder`, index + 1);
+ const newOrder = Number(trimmedOrderString);
+ if (!Number.isInteger(newOrder) || newOrder < 1) {
return;
}
- const newIndex = newOrder - 1;
- if (index !== newIndex) {
- updateSigningOrders(newIndex, index);
- triggerDragAndDrop(index, newIndex);
+ const currentSigners = form.getValues('signers');
+ const signer = currentSigners[index];
+
+ // Remove signer from current position and insert at new position
+ const remainingSigners = currentSigners.filter((_, idx) => idx !== index);
+ const newPosition = Math.min(Math.max(0, newOrder - 1), currentSigners.length - 1);
+ remainingSigners.splice(newPosition, 0, signer);
+
+ const updatedSigners = remainingSigners.map((s, idx) => ({
+ ...s,
+ signingOrder: idx + 1,
+ }));
+
+ form.setValue('signers', updatedSigners);
+
+ if (signer.role === RecipientRole.ASSISTANT && newPosition === remainingSigners.length - 1) {
+ toast({
+ title: _(msg`Warning: Assistant as last signer`),
+ description: _(
+ msg`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`,
+ ),
+ });
}
},
- [form, triggerDragAndDrop, updateSigningOrders],
+ [form, toast],
);
+ const handleRoleChange = useCallback(
+ (index: number, role: RecipientRole) => {
+ const currentSigners = form.getValues('signers');
+ const signingOrder = form.getValues('signingOrder');
+
+ // Handle parallel to sequential conversion for assistants
+ if (role === RecipientRole.ASSISTANT && signingOrder === DocumentSigningOrder.PARALLEL) {
+ form.setValue('signingOrder', DocumentSigningOrder.SEQUENTIAL);
+ toast({
+ title: _(msg`Signing order is enabled.`),
+ description: _(msg`You cannot add assistants when signing order is disabled.`),
+ variant: 'destructive',
+ });
+ return;
+ }
+
+ const updatedSigners = currentSigners.map((signer, idx) => ({
+ ...signer,
+ role: idx === index ? role : signer.role,
+ signingOrder: idx + 1,
+ }));
+
+ form.setValue('signers', updatedSigners);
+
+ if (role === RecipientRole.ASSISTANT && index === updatedSigners.length - 1) {
+ toast({
+ title: _(msg`Warning: Assistant as last signer`),
+ description: _(
+ msg`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`,
+ ),
+ });
+ }
+ },
+ [form, toast],
+ );
+
+ const [showSigningOrderConfirmation, setShowSigningOrderConfirmation] = useState(false);
+
+ const handleSigningOrderDisable = useCallback(() => {
+ setShowSigningOrderConfirmation(false);
+
+ const currentSigners = form.getValues('signers');
+ const updatedSigners = currentSigners.map((signer) => ({
+ ...signer,
+ role: signer.role === RecipientRole.ASSISTANT ? RecipientRole.SIGNER : signer.role,
+ }));
+
+ form.setValue('signers', updatedSigners);
+ form.setValue('signingOrder', DocumentSigningOrder.PARALLEL);
+ }, [form]);
+
return (
<>
+ onCheckedChange={(checked) => {
+ if (
+ !checked &&
+ watchedSigners.some((s) => s.role === RecipientRole.ASSISTANT)
+ ) {
+ setShowSigningOrderConfirmation(true);
+ return;
+ }
+
field.onChange(
checked ? DocumentSigningOrder.SEQUENTIAL : DocumentSigningOrder.PARALLEL,
- )
- }
+ );
+ }}
disabled={isSubmitting}
/>
@@ -548,7 +615,10 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
+ handleRoleChange(index, value as RecipientRole)
+ }
disabled={isSubmitting}
hideCCRecipients={isSignerDirectRecipient(signer)}
/>
@@ -669,6 +739,12 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
onGoNextClick={() => void onFormSubmit()}
/>
+
+
>
);
};