feat: generate recipients and fields based on recipients

This commit is contained in:
Ephraim Atta-Duncan
2025-11-17 01:44:22 +00:00
parent 9e0f07f806
commit dbed8b362e
15 changed files with 1539 additions and 241 deletions

View File

@ -0,0 +1,106 @@
import { useEffect, useState } from 'react';
import { Trans } from '@lingui/react/macro';
import { LoaderIcon } from 'lucide-react';
import { match } from 'ts-pattern';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
type DocumentAiStep = 'PROMPT' | 'PROCESSING';
export type DocumentAiPromptDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
onAccept: () => Promise<void> | void;
onSkip: () => void;
};
export const DocumentAiPromptDialog = ({
open,
onOpenChange,
onAccept,
onSkip,
}: DocumentAiPromptDialogProps) => {
const [currentStep, setCurrentStep] = useState<DocumentAiStep>('PROMPT');
// Reset to first step when dialog closes
useEffect(() => {
if (!open) {
setCurrentStep('PROMPT');
}
}, [open]);
const handleUseAi = () => {
setCurrentStep('PROCESSING');
Promise.resolve(onAccept()).catch(() => {
setCurrentStep('PROMPT');
});
};
const handleSkip = () => {
onSkip();
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<fieldset disabled={currentStep === 'PROCESSING'}>
<AnimateGenericFadeInOut motionKey={currentStep}>
{match(currentStep)
.with('PROMPT', () => (
<>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Trans>Use AI to prepare your document?</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
Would you like to use AI to automatically add recipients to your document?
This can save you time in setting up your document.
</Trans>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="button" variant="secondary" onClick={handleSkip}>
<Trans>Skip for now</Trans>
</Button>
<Button type="button" onClick={handleUseAi}>
<Trans>Use AI</Trans>
</Button>
</DialogFooter>
</>
))
.with('PROCESSING', () => (
<>
<DialogHeader>
<DialogTitle className="flex items-center justify-center gap-2">
<LoaderIcon className="h-5 w-5 animate-spin" />
<Trans>Analyzing your document</Trans>
</DialogTitle>
<DialogDescription className="text-center">
<Trans>
Our AI is scanning your document to detect recipient names, emails, and
signing order.
</Trans>
</DialogDescription>
</DialogHeader>
</>
))
.exhaustive()}
</AnimateGenericFadeInOut>
</fieldset>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,366 @@
import { useEffect, useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { RecipientRole } from '@prisma/client';
import { PlusIcon, TrashIcon } from 'lucide-react';
import { type FieldError, useFieldArray, useForm } from 'react-hook-form';
import { z } from 'zod';
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { nanoid } from '@documenso/lib/universal/id';
import { trpc } from '@documenso/trpc/react';
import { RecipientAutoCompleteInput } from '@documenso/ui/components/recipient/recipient-autocomplete-input';
import type { RecipientAutoCompleteOption } from '@documenso/ui/components/recipient/recipient-autocomplete-input';
import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
import type { RecipientForCreation } from '~/utils/analyze-ai-recipients';
const ZDocumentAiRecipientSchema = z.object({
formId: z.string().min(1),
name: z
.string()
.min(1, { message: msg`Name is required`.id })
.max(255),
email: z
.string()
.min(1, { message: msg`Email is required`.id })
.email({ message: msg`Invalid email`.id })
.max(254),
role: z.nativeEnum(RecipientRole),
});
const ZDocumentAiRecipientsForm = z.object({
recipients: z
.array(ZDocumentAiRecipientSchema)
.min(1, { message: msg`Please add at least one recipient`.id }),
});
type TDocumentAiRecipientsForm = z.infer<typeof ZDocumentAiRecipientsForm>;
export type DocumentAiRecipientsDialogProps = {
open: boolean;
recipients: RecipientForCreation[] | null;
onOpenChange: (open: boolean) => void;
onCancel: () => void;
onSubmit: (recipients: RecipientForCreation[]) => Promise<void> | void;
};
export const DocumentAiRecipientsDialog = ({
open,
recipients,
onOpenChange,
onCancel,
onSubmit,
}: DocumentAiRecipientsDialogProps) => {
const { t } = useLingui();
const [recipientSearchQuery, setRecipientSearchQuery] = useState('');
const debouncedRecipientSearchQuery = useDebouncedValue(recipientSearchQuery, 500);
const { data: recipientSuggestionsData, isLoading } = trpc.recipient.suggestions.find.useQuery(
{
query: debouncedRecipientSearchQuery,
},
{
enabled: debouncedRecipientSearchQuery.length > 1,
},
);
const recipientSuggestions = recipientSuggestionsData?.results || [];
const defaultRecipients = useMemo(() => {
if (recipients && recipients.length > 0) {
const sorted = [...recipients].sort((a, b) => {
const orderA = a.signingOrder ?? 0;
const orderB = b.signingOrder ?? 0;
return orderA - orderB;
});
return sorted.map((recipient) => ({
formId: nanoid(),
name: recipient.name,
email: recipient.email,
role: recipient.role,
}));
}
return [
{
formId: nanoid(),
name: '',
email: '',
role: RecipientRole.SIGNER,
},
];
}, [recipients]);
const form = useForm<TDocumentAiRecipientsForm>({
resolver: zodResolver(ZDocumentAiRecipientsForm),
defaultValues: {
recipients: defaultRecipients,
},
});
const {
formState: { isSubmitting },
} = form;
useEffect(() => {
form.reset({
recipients: defaultRecipients,
});
}, [defaultRecipients, form]);
const { fields, append, remove } = useFieldArray({
control: form.control,
name: 'recipients',
});
const handleRecipientAutoCompleteSelect = (
index: number,
suggestion: RecipientAutoCompleteOption,
) => {
form.setValue(`recipients.${index}.email`, suggestion.email);
form.setValue(`recipients.${index}.name`, suggestion.name ?? suggestion.email);
};
const handleAddSigner = () => {
append({
formId: nanoid(),
name: '',
email: '',
role: RecipientRole.SIGNER,
});
};
const handleRemoveSigner = (index: number) => {
remove(index);
};
const handleSubmit = form.handleSubmit(async (values) => {
const normalizedRecipients: RecipientForCreation[] = values.recipients.map(
(recipient, index) => ({
name: recipient.name.trim(),
email: recipient.email.trim(),
role: recipient.role,
signingOrder: index + 1,
}),
);
try {
await onSubmit(normalizedRecipients);
} catch {
// Form level errors are surfaced via toasts in the parent. Keep the dialog open.
}
});
const getRecipientsRootError = (
error: typeof form.formState.errors.recipients,
): FieldError | undefined => {
if (typeof error !== 'object' || !error || !('root' in error)) {
return undefined;
}
const candidate = (error as { root?: FieldError }).root;
return typeof candidate === 'object' ? candidate : undefined;
};
const recipientsRootError = getRecipientsRootError(form.formState.errors.recipients);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] max-w-4xl overflow-y-auto">
<DialogHeader>
<DialogTitle>
<Trans>Review detected recipients</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
Confirm, edit, or add recipients before continuing. You can adjust any information
below before importing it into your document.
</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-4">
{fields.map((field, index) => (
<div
key={field.id}
className="flex flex-col gap-4 md:flex-row md:items-center md:gap-x-2"
>
<FormField
control={form.control}
name={`recipients.${index}.email`}
render={({ field: emailField }) => (
<FormItem
className={cn('relative w-full', {
'mb-6':
form.formState.errors.recipients?.[index] &&
!form.formState.errors.recipients[index]?.email,
})}
>
{index === 0 && (
<FormLabel required>
<Trans>Email</Trans>
</FormLabel>
)}
<FormControl>
<RecipientAutoCompleteInput
type="email"
placeholder={t`Email`}
value={emailField.value}
options={recipientSuggestions}
onSelect={(suggestion) =>
handleRecipientAutoCompleteSelect(index, suggestion)
}
onSearchQueryChange={(query) => {
emailField.onChange(query);
setRecipientSearchQuery(query);
}}
loading={isLoading}
disabled={isSubmitting}
maxLength={254}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`recipients.${index}.name`}
render={({ field: nameField }) => (
<FormItem
className={cn('w-full', {
'mb-6':
form.formState.errors.recipients?.[index] &&
!form.formState.errors.recipients[index]?.name,
})}
>
{index === 0 && (
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
)}
<FormControl>
<RecipientAutoCompleteInput
type="text"
placeholder={t`Name`}
value={nameField.value}
options={recipientSuggestions}
onSelect={(suggestion) =>
handleRecipientAutoCompleteSelect(index, suggestion)
}
onSearchQueryChange={(query) => {
nameField.onChange(query);
setRecipientSearchQuery(query);
}}
loading={isLoading}
disabled={isSubmitting}
maxLength={255}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`recipients.${index}.role`}
render={({ field: roleField }) => (
<FormItem
className={cn('mt-2 w-full md:mt-auto md:w-fit', {
'mb-6':
form.formState.errors.recipients?.[index] &&
!form.formState.errors.recipients[index]?.role,
})}
>
{index === 0 && (
<FormLabel>
<Trans>Role</Trans>
</FormLabel>
)}
<FormControl>
<RecipientRoleSelect
value={roleField.value}
onValueChange={roleField.onChange}
disabled={isSubmitting}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="button"
variant="ghost"
className={cn('mt-2 w-full px-2 md:mt-auto md:w-auto', {
'mb-6': form.formState.errors.recipients?.[index],
})}
onClick={() => handleRemoveSigner(index)}
disabled={isSubmitting || fields.length === 1}
>
<TrashIcon className="h-4 w-4" />
</Button>
</div>
))}
<FormErrorMessage className="mt-2" error={recipientsRootError} />
<div className="flex">
<Button
type="button"
variant="outline"
onClick={handleAddSigner}
className="w-full md:w-auto"
disabled={isSubmitting}
>
<PlusIcon className="mr-2 h-4 w-4" />
<Trans>Add signer</Trans>
</Button>
</div>
</div>
<DialogFooter className="flex flex-col gap-2 sm:flex-row sm:justify-end sm:gap-3">
<div className="flex flex-col gap-2 sm:flex-row sm:gap-3">
<Button type="button" variant="ghost" onClick={onCancel} disabled={isSubmitting}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={isSubmitting}>
<Trans>Use recipients</Trans>
</Button>
</div>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -137,13 +137,13 @@ const enforceMinimumFieldDimensions = (params: {
};
const processAllPagesWithAI = async (params: {
documentDataId: string;
envelopeId: string;
onProgress: (current: number, total: number) => void;
}): Promise<{
fieldsPerPage: Map<number, TDetectedFormField[]>;
errors: Map<number, Error>;
}> => {
const { documentDataId, onProgress } = params;
const { envelopeId, onProgress } = params;
const fieldsPerPage = new Map<number, TDetectedFormField[]>();
const errors = new Map<number, Error>();
@ -156,7 +156,7 @@ const processAllPagesWithAI = async (params: {
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ documentId: documentDataId }),
body: JSON.stringify({ envelopeId }),
credentials: 'include',
});
@ -359,10 +359,10 @@ export const EnvelopeEditorFieldsPage = () => {
setProcessingProgress(null);
try {
if (!editorFields.selectedRecipient || !currentEnvelopeItem) {
if (!currentEnvelopeItem) {
toast({
title: t`Warning`,
description: t`Please select a recipient before adding fields.`,
title: t`Error`,
description: t`No document selected. Please reload the page and try again.`,
variant: 'destructive',
});
return;
@ -378,7 +378,7 @@ export const EnvelopeEditorFieldsPage = () => {
}
const { fieldsPerPage, errors } = await processAllPagesWithAI({
documentDataId: currentEnvelopeItem.documentDataId,
envelopeId: envelope.id,
onProgress: (current, total) => {
setProcessingProgress({ current, total });
},
@ -412,6 +412,22 @@ export const EnvelopeEditorFieldsPage = () => {
}
const fieldType = detected.label as FieldType;
const resolvedRecipientId =
envelope.recipients.find(
(recipient) => recipient.id === detected.recipientId,
)?.id ??
editorFields.selectedRecipient?.id ??
envelope.recipients[0]?.id;
if (!resolvedRecipientId) {
console.warn(
'Skipping detected field because no recipient could be resolved',
{
detectedRecipientId: detected.recipientId,
},
);
continue;
}
try {
editorFields.addField({
@ -422,7 +438,7 @@ export const EnvelopeEditorFieldsPage = () => {
positionY,
width,
height,
recipientId: editorFields.selectedRecipient.id,
recipientId: resolvedRecipientId,
fieldMeta: structuredClone(FIELD_META_DEFAULT_VALUES[fieldType]),
});
totalAdded++;

View File

@ -27,7 +27,14 @@ import type { TCreateEnvelopePayload } from '@documenso/trpc/server/envelope-rou
import { cn } from '@documenso/ui/lib/utils';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { DocumentAiPromptDialog } from '~/components/dialogs/document-ai-prompt-dialog';
import { DocumentAiRecipientsDialog } from '~/components/dialogs/document-ai-recipients-dialog';
import { useCurrentTeam } from '~/providers/team';
import {
type RecipientForCreation,
analyzeRecipientsFromDocument,
ensureRecipientEmails,
} from '~/utils/analyze-ai-recipients';
export interface EnvelopeDropZoneWrapperProps {
children: ReactNode;
@ -52,6 +59,11 @@ export const EnvelopeDropZoneWrapper = ({
const organisation = useCurrentOrganisation();
const [isLoading, setIsLoading] = useState(false);
const [showAiPromptDialog, setShowAiPromptDialog] = useState(false);
const [uploadedDocumentId, setUploadedDocumentId] = useState<string | null>(null);
const [pendingRecipients, setPendingRecipients] = useState<RecipientForCreation[] | null>(null);
const [showAiRecipientsDialog, setShowAiRecipientsDialog] = useState(false);
const [shouldNavigateAfterPromptClose, setShouldNavigateAfterPromptClose] = useState(true);
const userTimezone =
TIME_ZONES.find((timezone) => timezone === Intl.DateTimeFormat().resolvedOptions().timeZone) ??
@ -60,6 +72,7 @@ export const EnvelopeDropZoneWrapper = ({
const { quota, remaining, refreshLimits, maximumEnvelopeItemCount } = useLimits();
const { mutateAsync: createEnvelope } = trpc.envelope.create.useMutation();
const { mutateAsync: createRecipients } = trpc.envelope.recipient.createMany.useMutation();
const isUploadDisabled = remaining.documents === 0 || !user.emailVerified;
@ -108,14 +121,18 @@ export const EnvelopeDropZoneWrapper = ({
documentId: id,
timestamp: new Date().toISOString(),
});
// Show AI prompt dialog for documents
setUploadedDocumentId(id);
setPendingRecipients(null);
setShowAiRecipientsDialog(false);
setShouldNavigateAfterPromptClose(true);
setShowAiPromptDialog(true);
} else {
// Templates - navigate immediately
const pathPrefix = formatTemplatesPath(team.url);
await navigate(`${pathPrefix}/${id}/edit`);
}
const pathPrefix =
type === EnvelopeType.DOCUMENT
? formatDocumentsPath(team.url)
: formatTemplatesPath(team.url);
await navigate(`${pathPrefix}/${id}/edit`);
} catch (err) {
const error = AppError.parseError(err);
@ -201,6 +218,115 @@ export const EnvelopeDropZoneWrapper = ({
variant: 'destructive',
});
};
const navigateToEnvelopeEditor = () => {
if (!uploadedDocumentId) {
return;
}
const pathPrefix = formatDocumentsPath(team.url);
void navigate(`${pathPrefix}/${uploadedDocumentId}/edit`);
};
const handleAiAccept = async () => {
if (!uploadedDocumentId) {
return;
}
try {
const recipients = await analyzeRecipientsFromDocument(uploadedDocumentId);
if (recipients.length === 0) {
toast({
title: t`No recipients detected`,
description: t`You can add recipients manually in the editor`,
duration: 5000,
});
throw new Error('NO_RECIPIENTS_DETECTED');
}
const recipientsWithEmails = ensureRecipientEmails(recipients, uploadedDocumentId);
setPendingRecipients(recipientsWithEmails);
setShouldNavigateAfterPromptClose(false);
setShowAiPromptDialog(false);
setShowAiRecipientsDialog(true);
} catch (error) {
if (!(error instanceof Error && error.message === 'NO_RECIPIENTS_DETECTED')) {
const parsedError = AppError.parseError(error);
toast({
title: t`Failed to analyze recipients`,
description: parsedError.userMessage || t`You can add recipients manually in the editor`,
variant: 'destructive',
duration: 7500,
});
}
throw error;
}
};
const handleAiSkip = () => {
setShouldNavigateAfterPromptClose(true);
setShowAiPromptDialog(false);
navigateToEnvelopeEditor();
};
const handleRecipientsCancel = () => {
setShowAiRecipientsDialog(false);
setPendingRecipients(null);
navigateToEnvelopeEditor();
};
const handleRecipientsConfirm = async (recipientsToCreate: RecipientForCreation[]) => {
if (!uploadedDocumentId) {
return;
}
try {
await createRecipients({
envelopeId: uploadedDocumentId,
data: recipientsToCreate,
});
toast({
title: t`Recipients added`,
description: t`Successfully detected ${recipientsToCreate.length} recipient(s)`,
duration: 5000,
});
setShowAiRecipientsDialog(false);
setPendingRecipients(null);
navigateToEnvelopeEditor();
} catch (error) {
const parsedError = AppError.parseError(error);
toast({
title: t`Failed to add recipients`,
description: parsedError.userMessage || t`Please review the recipients and try again`,
variant: 'destructive',
duration: 7500,
});
throw error;
}
};
const handlePromptDialogOpenChange = (open: boolean) => {
setShowAiPromptDialog(open);
if (open) {
setShouldNavigateAfterPromptClose(true);
return;
}
if (!open && shouldNavigateAfterPromptClose) {
navigateToEnvelopeEditor();
}
};
const { getRootProps, getInputProps, isDragActive } = useDropzone({
accept: {
'application/pdf': ['.pdf'],
@ -267,6 +393,27 @@ export const EnvelopeDropZoneWrapper = ({
</div>
</div>
)}
<DocumentAiPromptDialog
open={showAiPromptDialog}
onOpenChange={handlePromptDialogOpenChange}
onAccept={handleAiAccept}
onSkip={handleAiSkip}
/>
<DocumentAiRecipientsDialog
open={showAiRecipientsDialog}
recipients={pendingRecipients}
onOpenChange={(open) => {
if (!open) {
handleRecipientsCancel();
} else {
setShowAiRecipientsDialog(true);
}
}}
onCancel={handleRecipientsCancel}
onSubmit={handleRecipientsConfirm}
/>
</div>
);
};

View File

@ -27,7 +27,14 @@ import {
} from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { DocumentAiPromptDialog } from '~/components/dialogs/document-ai-prompt-dialog';
import { DocumentAiRecipientsDialog } from '~/components/dialogs/document-ai-recipients-dialog';
import { useCurrentTeam } from '~/providers/team';
import {
type RecipientForCreation,
analyzeRecipientsFromDocument,
ensureRecipientEmails,
} from '~/utils/analyze-ai-recipients';
export type EnvelopeUploadButtonProps = {
className?: string;
@ -55,8 +62,14 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
const { quota, remaining, refreshLimits, maximumEnvelopeItemCount } = useLimits();
const [isLoading, setIsLoading] = useState(false);
const [showAiPromptDialog, setShowAiPromptDialog] = useState(false);
const [uploadedDocumentId, setUploadedDocumentId] = useState<string | null>(null);
const [pendingRecipients, setPendingRecipients] = useState<RecipientForCreation[] | null>(null);
const [showAiRecipientsDialog, setShowAiRecipientsDialog] = useState(false);
const [shouldNavigateAfterPromptClose, setShouldNavigateAfterPromptClose] = useState(true);
const { mutateAsync: createEnvelope } = trpc.envelope.create.useMutation();
const { mutateAsync: createRecipients } = trpc.envelope.recipient.createMany.useMutation();
const disabledMessage = useMemo(() => {
if (organisation.subscription && remaining.documents === 0) {
@ -108,16 +121,29 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
? formatDocumentsPath(team.url)
: formatTemplatesPath(team.url);
await navigate(`${pathPrefix}/${id}/edit`);
// Show AI prompt dialog for documents only
if (type === EnvelopeType.DOCUMENT) {
setUploadedDocumentId(id);
setPendingRecipients(null);
setShowAiRecipientsDialog(false);
setShouldNavigateAfterPromptClose(true);
setShowAiPromptDialog(true);
toast({
title: type === EnvelopeType.DOCUMENT ? t`Document uploaded` : t`Template uploaded`,
description:
type === EnvelopeType.DOCUMENT
? t`Your document has been uploaded successfully.`
: t`Your template has been uploaded successfully.`,
duration: 5000,
});
toast({
title: t`Document uploaded`,
description: t`Your document has been uploaded successfully.`,
duration: 5000,
});
} else {
// Templates - navigate immediately
await navigate(`${pathPrefix}/${id}/edit`);
toast({
title: t`Template uploaded`,
description: t`Your template has been uploaded successfully.`,
duration: 5000,
});
}
} catch (err) {
const error = AppError.parseError(err);
@ -169,6 +195,114 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
});
};
const navigateToEnvelopeEditor = () => {
if (!uploadedDocumentId) {
return;
}
const pathPrefix = formatDocumentsPath(team.url);
void navigate(`${pathPrefix}/${uploadedDocumentId}/edit`);
};
const handleAiAccept = async () => {
if (!uploadedDocumentId) {
return;
}
try {
const recipients = await analyzeRecipientsFromDocument(uploadedDocumentId);
if (recipients.length === 0) {
toast({
title: t`No recipients detected`,
description: t`You can add recipients manually in the editor`,
duration: 5000,
});
throw new Error('NO_RECIPIENTS_DETECTED');
}
const recipientsWithEmails = ensureRecipientEmails(recipients, uploadedDocumentId);
setPendingRecipients(recipientsWithEmails);
setShouldNavigateAfterPromptClose(false);
setShowAiPromptDialog(false);
setShowAiRecipientsDialog(true);
} catch (err) {
if (!(err instanceof Error && err.message === 'NO_RECIPIENTS_DETECTED')) {
const error = AppError.parseError(err);
toast({
title: t`Failed to analyze recipients`,
description: error.userMessage || t`You can add recipients manually in the editor`,
variant: 'destructive',
duration: 7500,
});
}
throw err;
}
};
const handleAiSkip = () => {
setShouldNavigateAfterPromptClose(true);
setShowAiPromptDialog(false);
navigateToEnvelopeEditor();
};
const handleRecipientsCancel = () => {
setShowAiRecipientsDialog(false);
setPendingRecipients(null);
navigateToEnvelopeEditor();
};
const handleRecipientsConfirm = async (recipientsToCreate: RecipientForCreation[]) => {
if (!uploadedDocumentId) {
return;
}
try {
await createRecipients({
envelopeId: uploadedDocumentId,
data: recipientsToCreate,
});
toast({
title: t`Recipients added`,
description: t`Successfully detected ${recipientsToCreate.length} recipient(s)`,
duration: 5000,
});
setShowAiRecipientsDialog(false);
setPendingRecipients(null);
navigateToEnvelopeEditor();
} catch (err) {
const error = AppError.parseError(err);
toast({
title: t`Failed to add recipients`,
description: error.userMessage || t`Please review the recipients and try again`,
variant: 'destructive',
duration: 7500,
});
throw err;
}
};
const handlePromptDialogOpenChange = (open: boolean) => {
setShowAiPromptDialog(open);
if (open) {
setShouldNavigateAfterPromptClose(true);
return;
}
if (!open && shouldNavigateAfterPromptClose) {
navigateToEnvelopeEditor();
}
};
return (
<div className={cn('relative', className)}>
<TooltipProvider>
@ -201,6 +335,27 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
)}
</Tooltip>
</TooltipProvider>
<DocumentAiPromptDialog
open={showAiPromptDialog}
onOpenChange={handlePromptDialogOpenChange}
onAccept={handleAiAccept}
onSkip={handleAiSkip}
/>
<DocumentAiRecipientsDialog
open={showAiRecipientsDialog}
recipients={pendingRecipients}
onOpenChange={(open) => {
if (!open) {
handleRecipientsCancel();
} else {
setShowAiRecipientsDialog(true);
}
}}
onCancel={handleRecipientsCancel}
onSubmit={handleRecipientsConfirm}
/>
</div>
);
};

View File

@ -0,0 +1,93 @@
import { RecipientRole } from '@prisma/client';
import { AppError } from '@documenso/lib/errors/app-error';
export type AiRecipient = {
name: string;
email?: string;
role: 'SIGNER' | 'APPROVER' | 'CC';
signingOrder?: number;
};
const sanitizeEmailLocalPart = (value: string) => {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '.')
.replace(/\.+/g, '.')
.replace(/^\.+|\.+$/g, '')
.slice(0, 32);
};
export const createPlaceholderRecipientEmail = (
name: string,
envelopeId: string,
position: number,
) => {
const normalizedName = sanitizeEmailLocalPart(name);
const baseLocalPart = normalizedName ? `${normalizedName}.${position}` : `recipient-${position}`;
const envelopeSuffix = envelopeId
.replace(/[^a-z0-9]/gi, '')
.toLowerCase()
.slice(0, 6);
const suffix = envelopeSuffix ? `-${envelopeSuffix}` : '';
return `${baseLocalPart}${suffix}@documenso.ai`;
};
export const analyzeRecipientsFromDocument = async (envelopeId: string): Promise<AiRecipient[]> => {
try {
const response = await fetch('/api/ai/analyze-recipients', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ envelopeId }),
});
if (!response.ok) {
throw new Error('Failed to analyze recipients');
}
return (await response.json()) as AiRecipient[];
} catch (error) {
throw AppError.parseError(error);
}
};
export type RecipientForCreation = {
name: string;
email: string;
role: RecipientRole;
signingOrder?: number;
};
export const ensureRecipientEmails = (
recipients: AiRecipient[],
envelopeId: string,
): RecipientForCreation[] => {
let recipientIndex = 1;
const allowedRoles: RecipientRole[] = [
RecipientRole.SIGNER,
RecipientRole.APPROVER,
RecipientRole.CC,
];
return recipients.map((recipient) => {
const email =
recipient.email ??
createPlaceholderRecipientEmail(recipient.name, envelopeId, recipientIndex);
recipientIndex += 1;
const candidateRole = recipient.role as RecipientRole;
const normalizedRole = allowedRoles.includes(candidateRole)
? candidateRole
: RecipientRole.SIGNER;
return {
...recipient,
email,
role: normalizedRole,
};
});
};