diff --git a/apps/remix/app/components/dialogs/document-ai-prompt-dialog.tsx b/apps/remix/app/components/dialogs/document-ai-prompt-dialog.tsx new file mode 100644 index 000000000..7ad5cab57 --- /dev/null +++ b/apps/remix/app/components/dialogs/document-ai-prompt-dialog.tsx @@ -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; + onSkip: () => void; +}; + +export const DocumentAiPromptDialog = ({ + open, + onOpenChange, + onAccept, + onSkip, +}: DocumentAiPromptDialogProps) => { + const [currentStep, setCurrentStep] = useState('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 ( + + +
+ + {match(currentStep) + .with('PROMPT', () => ( + <> + + + Use AI to prepare your document? + + + + Would you like to use AI to automatically add recipients to your document? + This can save you time in setting up your document. + + + + + + + + + + )) + .with('PROCESSING', () => ( + <> + + + + Analyzing your document + + + + Our AI is scanning your document to detect recipient names, emails, and + signing order. + + + + + )) + .exhaustive()} + +
+
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/document-ai-recipients-dialog.tsx b/apps/remix/app/components/dialogs/document-ai-recipients-dialog.tsx new file mode 100644 index 000000000..b571da6e6 --- /dev/null +++ b/apps/remix/app/components/dialogs/document-ai-recipients-dialog.tsx @@ -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; + +export type DocumentAiRecipientsDialogProps = { + open: boolean; + recipients: RecipientForCreation[] | null; + onOpenChange: (open: boolean) => void; + onCancel: () => void; + onSubmit: (recipients: RecipientForCreation[]) => Promise | 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({ + 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 ( + + + + + Review detected recipients + + + + Confirm, edit, or add recipients before continuing. You can adjust any information + below before importing it into your document. + + + + +
+ +
+ {fields.map((field, index) => ( +
+ ( + + {index === 0 && ( + + Email + + )} + + + handleRecipientAutoCompleteSelect(index, suggestion) + } + onSearchQueryChange={(query) => { + emailField.onChange(query); + setRecipientSearchQuery(query); + }} + loading={isLoading} + disabled={isSubmitting} + maxLength={254} + /> + + + + )} + /> + + ( + + {index === 0 && ( + + Name + + )} + + + handleRecipientAutoCompleteSelect(index, suggestion) + } + onSearchQueryChange={(query) => { + nameField.onChange(query); + setRecipientSearchQuery(query); + }} + loading={isLoading} + disabled={isSubmitting} + maxLength={255} + /> + + + + )} + /> + + ( + + {index === 0 && ( + + Role + + )} + + + + + + )} + /> + + +
+ ))} + + + +
+ +
+
+ + +
+ + +
+
+
+ +
+
+ ); +}; diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx index 9c889ea37..9329d65f6 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx @@ -137,13 +137,13 @@ const enforceMinimumFieldDimensions = (params: { }; const processAllPagesWithAI = async (params: { - documentDataId: string; + envelopeId: string; onProgress: (current: number, total: number) => void; }): Promise<{ fieldsPerPage: Map; errors: Map; }> => { - const { documentDataId, onProgress } = params; + const { envelopeId, onProgress } = params; const fieldsPerPage = new Map(); const errors = new Map(); @@ -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++; diff --git a/apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx b/apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx index 374aa6d55..475e7fcee 100644 --- a/apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx +++ b/apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx @@ -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(null); + const [pendingRecipients, setPendingRecipients] = useState(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 = ({ )} + + + + { + if (!open) { + handleRecipientsCancel(); + } else { + setShowAiRecipientsDialog(true); + } + }} + onCancel={handleRecipientsCancel} + onSubmit={handleRecipientsConfirm} + /> ); }; diff --git a/apps/remix/app/components/general/envelope/envelope-upload-button.tsx b/apps/remix/app/components/general/envelope/envelope-upload-button.tsx index 96a025c22..0a4ba7752 100644 --- a/apps/remix/app/components/general/envelope/envelope-upload-button.tsx +++ b/apps/remix/app/components/general/envelope/envelope-upload-button.tsx @@ -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(null); + const [pendingRecipients, setPendingRecipients] = useState(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 (
@@ -201,6 +335,27 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo )} + + + + { + if (!open) { + handleRecipientsCancel(); + } else { + setShowAiRecipientsDialog(true); + } + }} + onCancel={handleRecipientsCancel} + onSubmit={handleRecipientsConfirm} + />
); }; diff --git a/apps/remix/app/utils/analyze-ai-recipients.ts b/apps/remix/app/utils/analyze-ai-recipients.ts new file mode 100644 index 000000000..80677a9c6 --- /dev/null +++ b/apps/remix/app/utils/analyze-ai-recipients.ts @@ -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 => { + 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, + }; + }); +}; diff --git a/apps/remix/server/api/ai.ts b/apps/remix/server/api/ai.ts index a80a6ca0c..7eaebde2c 100644 --- a/apps/remix/server/api/ai.ts +++ b/apps/remix/server/api/ai.ts @@ -35,10 +35,15 @@ import { prisma } from '@documenso/prisma'; import { generateObject } from 'ai'; import { Hono } from 'hono'; import sharp from 'sharp'; +import { z } from 'zod'; import type { HonoEnv } from '../router'; import { + type TAnalyzeRecipientsResponse, + type TDetectedRecipient, type TDetectFormFieldsResponse, + ZAnalyzeRecipientsRequestSchema, + ZDetectedRecipientLLMSchema, ZDetectedFormFieldSchema, ZDetectFormFieldsRequestSchema, } from './ai.types'; @@ -179,12 +184,47 @@ When detecting thin horizontal lines for SIGNATURE, INITIALS, NAME, EMAIL, DATE, - Expanded field: [ymin=420, xmin=200, ymax=500, xmax=600] (creates 80-unit tall field) - This gives comfortable signing space while respecting the form layout`; +type FieldDetectionRecipient = { + id: number; + name: string | null; + email: string | null; + role: string; + signingOrder: number | null; +}; + +const buildFieldDetectionPrompt = (recipients: FieldDetectionRecipient[]) => { + if (recipients.length === 0) { + return detectObjectsPrompt; + } + + const directory = recipients + .map((recipient, index) => { + const name = recipient.name?.trim() || `Recipient ${index + 1}`; + const details = [`name: "${name}"`, `role: ${recipient.role}`]; + + if (recipient.email) { + details.push(`email: ${recipient.email}`); + } + + if (typeof recipient.signingOrder === 'number') { + details.push(`signingOrder: ${recipient.signingOrder}`); + } + + return `ID ${recipient.id} → ${details.join(', ')}`; + }) + .join('\n'); + + return `${detectObjectsPrompt}\n\nRECIPIENT DIRECTORY:\n${directory}\n\nRECIPIENT ASSIGNMENT RULES:\n1. Every detected field MUST include a "recipientId" taken from the directory above.\n2. Match printed names, role labels ("Buyer", "Seller"), or instructions near the field to the closest recipient.\n3. When the document references numbered signers (Signer 1, Signer 2, etc.), align them with signingOrder when provided.\n4. If a name exactly matches a recipient, always use that recipient's ID.\n5. When context is ambiguous, distribute fields logically across recipients instead of assigning all fields to one person.\n6. Never invent new recipients or IDs—only use those in the directory.`; +}; + const runFormFieldDetection = async ( imageBuffer: Buffer, pageNumber: number, + recipients: FieldDetectionRecipient[], ): Promise => { const compressedImageBuffer = await resizeAndCompressImage(imageBuffer); const base64Image = compressedImageBuffer.toString('base64'); + const prompt = buildFieldDetectionPrompt(recipients); const result = await generateObject({ model: 'google/gemini-2.5-pro', @@ -200,223 +240,513 @@ const runFormFieldDetection = async ( }, { type: 'text', - text: detectObjectsPrompt, + text: prompt, }, ], }, ], }); - return result.object.map((field) => ({ - ...field, - pageNumber, - })); -}; + const recipientIds = new Set(recipients.map((recipient) => recipient.id)); + const fallbackRecipientId = recipients[0]?.id; -export const aiRoute = new Hono().post('/detect-form-fields', async (c) => { - try { - const { user } = await getSession(c.req.raw); - - const body = await c.req.json(); - const parsed = ZDetectFormFieldsRequestSchema.safeParse(body); - - if (!parsed.success) { - throw new AppError(AppErrorCode.INVALID_REQUEST, { - message: 'Document ID is required', - userMessage: 'Please provide a valid document ID.', - }); - } - - const { documentId } = parsed.data; - - const documentData = await prisma.documentData.findUnique({ - where: { id: documentId }, - include: { - envelopeItem: { - include: { - envelope: { - select: { - userId: true, - teamId: true, - }, - }, - }, - }, - }, - }); - - if (!documentData || !documentData.envelopeItem) { - throw new AppError(AppErrorCode.NOT_FOUND, { - message: `Document data not found: ${documentId}`, - userMessage: 'The requested document does not exist.', - }); - } - - const envelope = documentData.envelopeItem.envelope; - - const isDirectOwner = envelope.userId === user.id; - - let hasTeamAccess = false; - if (envelope.teamId) { - try { - await getTeamById({ teamId: envelope.teamId, userId: user.id }); - hasTeamAccess = true; - } catch (error) { - hasTeamAccess = false; - } - } - - if (!isDirectOwner && !hasTeamAccess) { - throw new AppError(AppErrorCode.UNAUTHORIZED, { - message: `User ${user.id} does not have access to document ${documentId}`, - userMessage: 'You do not have permission to access this document.', - }); - } - - const pdfBytes = await getFileServerSide({ - type: documentData.type, - data: documentData.initialData || documentData.data, - }); - - const renderedPages = await renderPdfToImage(pdfBytes); - - const results = await Promise.allSettled( - renderedPages.map(async (page) => { - return await runFormFieldDetection(page.image, page.pageNumber); - }), - ); - - const detectedFields: TDetectFormFieldsResponse = []; - for (const [index, result] of results.entries()) { - if (result.status === 'fulfilled') { - detectedFields.push(...result.value); - } else { - const pageNumber = renderedPages[index]?.pageNumber ?? index + 1; - console.error(`Failed to detect fields on page ${pageNumber}:`, result.reason); - } - } - - if (env('NEXT_PUBLIC_AI_DEBUG_PREVIEW') === 'true') { - const debugDir = join(process.cwd(), '..', '..', 'packages', 'assets', 'ai-previews'); - await mkdir(debugDir, { recursive: true }); - - const now = new Date(); - const timestamp = now - .toISOString() - .replace(/[-:]/g, '') - .replace(/\..+/, '') - .replace('T', '_'); - - for (const page of renderedPages) { - const padding = { left: 80, top: 20, right: 20, bottom: 40 }; - const canvas = new Canvas( - page.width + padding.left + padding.right, - page.height + padding.top + padding.bottom, - ); - const ctx = canvas.getContext('2d'); - - const img = new Image(); - img.src = page.image; - ctx.drawImage(img, padding.left, padding.top); - - ctx.strokeStyle = 'rgba(255, 0, 0, 0.5)'; - ctx.lineWidth = 1; - - for (let i = 0; i <= 1000; i += 100) { - const x = padding.left + (i / 1000) * page.width; - ctx.beginPath(); - ctx.moveTo(x, padding.top); - ctx.lineTo(x, page.height + padding.top); - ctx.stroke(); - } - - for (let i = 0; i <= 1000; i += 100) { - const y = padding.top + (i / 1000) * page.height; - ctx.beginPath(); - ctx.moveTo(padding.left, y); - ctx.lineTo(page.width + padding.left, y); - ctx.stroke(); - } - - const colors = ['#FF0000', '#00FF00', '#0000FF', '#FFFF00', '#FF00FF', '#00FFFF']; - - const pageFields = detectedFields.filter((f) => f.pageNumber === page.pageNumber); - pageFields.forEach((field, index) => { - const [ymin, xmin, ymax, xmax] = field.boundingBox.map((coord) => coord / 1000); - - const x = xmin * page.width + padding.left; - const y = ymin * page.height + padding.top; - const width = (xmax - xmin) * page.width; - const height = (ymax - ymin) * page.height; - - ctx.strokeStyle = colors[index % colors.length]; - ctx.lineWidth = 5; - ctx.strokeRect(x, y, width, height); - - ctx.fillStyle = colors[index % colors.length]; - ctx.font = '20px Arial'; - ctx.fillText(field.label, x, y - 5); - }); - - ctx.strokeStyle = '#000000'; - ctx.lineWidth = 1; - ctx.font = '26px Arial'; - - ctx.beginPath(); - ctx.moveTo(padding.left, padding.top); - ctx.lineTo(padding.left, page.height + padding.top); - ctx.stroke(); - - ctx.textAlign = 'right'; - ctx.textBaseline = 'middle'; - for (let i = 0; i <= 1000; i += 100) { - const y = padding.top + (i / 1000) * page.height; - ctx.fillStyle = '#000000'; - ctx.fillText(i.toString(), padding.left - 5, y); - - ctx.beginPath(); - ctx.moveTo(padding.left - 5, y); - ctx.lineTo(padding.left, y); - ctx.stroke(); - } - - ctx.beginPath(); - ctx.moveTo(padding.left, page.height + padding.top); - ctx.lineTo(page.width + padding.left, page.height + padding.top); - ctx.stroke(); - - ctx.textAlign = 'center'; - ctx.textBaseline = 'top'; - for (let i = 0; i <= 1000; i += 100) { - const x = padding.left + (i / 1000) * page.width; - ctx.fillStyle = '#000000'; - ctx.fillText(i.toString(), x, page.height + padding.top + 5); - - ctx.beginPath(); - ctx.moveTo(x, page.height + padding.top); - ctx.lineTo(x, page.height + padding.top + 5); - ctx.stroke(); - } - - const outputFilename = `detected_form_fields_${timestamp}_page_${page.pageNumber}.png`; - const outputPath = join(debugDir, outputFilename); - - const pngBuffer = await canvas.toBuffer('png'); - await writeFile(outputPath, new Uint8Array(pngBuffer)); - } - } - - return c.json(detectedFields); - } catch (error) { - if (error instanceof AppError) { - throw error; - } - - console.error('Failed to detect form fields from PDF:', error); - - throw new AppError(AppErrorCode.UNKNOWN_ERROR, { - message: `Failed to detect form fields from PDF: ${error instanceof Error ? error.message : String(error)}`, - userMessage: 'An error occurred while detecting form fields. Please try again.', + if (fallbackRecipientId === undefined) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: 'Unable to assign recipients because no recipients were provided', + userMessage: 'Please add at least one recipient before detecting form fields.', }); } -}); + + return result.object.map((field) => { + let recipientId = field.recipientId; + + if (!recipientIds.has(recipientId)) { + console.warn( + 'AI returned invalid recipientId for detected field, defaulting to first recipient', + { + field, + fallbackRecipientId, + }, + ); + + recipientId = fallbackRecipientId; + } + + return { + ...field, + recipientId, + pageNumber, + }; + }); +}; + +const MAX_PAGES_FOR_RECIPIENT_ANALYSIS = 3; + +const recipientEmailSchema = z.string().email(); + +const sanitizeEmailLocalPart = (value: string) => { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, '.') + .replace(/\.+/g, '.') + .replace(/^\.+|\.+$/g, '') + .slice(0, 32); +}; + +const createPlaceholderRecipientEmail = (name: string, envelopeId: string, position: number) => { + const normalizedName = sanitizeEmailLocalPart(name); + const baseLocalPart = normalizedName ? `${normalizedName}.${position}` : `recipient-${position}`; + const sanitizedEnvelopeSuffix = envelopeId + .replace(/[^a-z0-9]/gi, '') + .toLowerCase() + .slice(0, 6); + const suffix = sanitizedEnvelopeSuffix ? `-${sanitizedEnvelopeSuffix}` : ''; + + return `${baseLocalPart}${suffix}@documenso.ai`; +}; + +const resolveRecipientEmail = ( + candidateEmail: string | undefined, + name: string, + envelopeId: string, + position: number, +) => { + if (candidateEmail) { + const trimmedEmail = candidateEmail.trim(); + + if (recipientEmailSchema.safeParse(trimmedEmail).success) { + return trimmedEmail; + } + } + + return createPlaceholderRecipientEmail(name, envelopeId, position); +}; + +const authorizeDocumentAccess = async (envelopeId: string, userId: number) => { + const envelope = await prisma.envelope.findUnique({ + where: { id: envelopeId }, + include: { + envelopeItems: { + include: { + documentData: true, + }, + }, + }, + }); + + if (!envelope || !envelope.envelopeItems || envelope.envelopeItems.length === 0) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: `Envelope not found: ${envelopeId}`, + userMessage: 'The requested document does not exist.', + }); + } + + const isDirectOwner = envelope.userId === userId; + + let hasTeamAccess = false; + if (envelope.teamId) { + try { + await getTeamById({ teamId: envelope.teamId, userId }); + hasTeamAccess = true; + } catch { + hasTeamAccess = false; + } + } + + if (!isDirectOwner && !hasTeamAccess) { + throw new AppError(AppErrorCode.UNAUTHORIZED, { + message: `User ${userId} does not have access to envelope ${envelopeId}`, + userMessage: 'You do not have permission to access this document.', + }); + } + + // Return the first document data from the envelope + const documentData = envelope.envelopeItems[0]?.documentData; + + if (!documentData) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: `Document data not found in envelope: ${envelopeId}`, + userMessage: 'The requested document does not exist.', + }); + } + + return documentData; +}; + +const analyzeRecipientsPrompt = `You are analyzing a document to identify recipients who need to sign, approve, or receive copies. + +TASK: Extract recipient information from this document. + +RECIPIENT TYPES: +- SIGNER: People who must sign the document (look for signature lines, "Signed by:", "Signature:", "X____") +- APPROVER: People who must review/approve before signing (look for "Approved by:", "Reviewed by:", "Approval:") +- CC: People who receive a copy for information only (look for "CC:", "Copy to:", "For information:") + +EXTRACTION RULES: +1. Look for signature lines with names printed above, below, or near them +2. Check for explicit labels like "Name:", "Signer:", "Party:", "Recipient:" +3. Look for "Approved by:", "Reviewed by:", "CC:" sections +4. Extract FULL NAMES as they appear in the document +5. If an email address is visible near a name, include it exactly in the "email" field +6. If NO email is found, create a realistic placeholder email using the person's name and the domain "documenso.ai" (e.g., john.doe@documenso.ai). Every recipient MUST have an email. +7. Ensure placeholder emails look unique when multiple people share the same name (append a number if needed) +8. Assign signing order based on document flow (numbered items, "First signer:", "Second signer:", or top-to-bottom sequence) + +IMPORTANT: +- Only extract recipients explicitly mentioned in the document +- Email is mandatory for every recipient (real, sample, or placeholder derived from the document text) +- Default role is SIGNER if unclear (signature lines = SIGNER) +- Signing order starts at 1 (first signer = 1, second = 2, etc.) +- If no clear ordering, omit signingOrder +- Return empty array if absolutely no recipients can be detected +- Do NOT invent recipients - only extract what's clearly present + +EXAMPLES: +Good: + - "Signed: _________ John Doe" → { name: "John Doe", role: "SIGNER", signingOrder: 1 } + - "Approved by: Jane Smith (jane@example.com)" → { name: "Jane Smith", email: "jane@example.com", role: "APPROVER" } + - "CC: Legal Team" → { name: "Legal Team", role: "CC" } + +Bad: + - Extracting the document title as a recipient name + - Making up email addresses that aren't in the document + - Adding people not mentioned in the document`; + +export const aiRoute = new Hono() + .post('/detect-form-fields', async (c) => { + try { + const { user } = await getSession(c.req.raw); + + const body = await c.req.json(); + const parsed = ZDetectFormFieldsRequestSchema.safeParse(body); + + if (!parsed.success) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: 'Envelope ID is required', + userMessage: 'Please provide a valid envelope ID.', + }); + } + + const { envelopeId } = parsed.data; + + // Use shared authorization function + const documentData = await authorizeDocumentAccess(envelopeId, user.id); + + const envelopeRecipients = await prisma.recipient.findMany({ + where: { envelopeId }, + select: { + id: true, + name: true, + email: true, + role: true, + signingOrder: true, + }, + }); + + if (envelopeRecipients.length === 0) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: `No recipients found for envelope ${envelopeId}`, + userMessage: 'Please add at least one recipient before detecting form fields.', + }); + } + + const rolePriority: Record = { + SIGNER: 0, + APPROVER: 1, + CC: 2, + }; + + const detectionRecipients: FieldDetectionRecipient[] = envelopeRecipients + .slice() + .sort((a, b) => { + const roleDiff = (rolePriority[a.role] ?? 3) - (rolePriority[b.role] ?? 3); + if (roleDiff !== 0) { + return roleDiff; + } + + const aOrder = + typeof a.signingOrder === 'number' ? a.signingOrder : Number.MAX_SAFE_INTEGER; + const bOrder = + typeof b.signingOrder === 'number' ? b.signingOrder : Number.MAX_SAFE_INTEGER; + + if (aOrder !== bOrder) { + return aOrder - bOrder; + } + + return a.id - b.id; + }) + .map((recipient) => ({ + id: recipient.id, + name: recipient.name, + email: recipient.email, + role: recipient.role, + signingOrder: recipient.signingOrder, + })); + + const pdfBytes = await getFileServerSide({ + type: documentData.type, + data: documentData.initialData || documentData.data, + }); + + const renderedPages = await renderPdfToImage(pdfBytes); + + const results = await Promise.allSettled( + renderedPages.map(async (page) => { + return await runFormFieldDetection(page.image, page.pageNumber, detectionRecipients); + }), + ); + + const detectedFields: TDetectFormFieldsResponse = []; + for (const [index, result] of results.entries()) { + if (result.status === 'fulfilled') { + detectedFields.push(...result.value); + } else { + const pageNumber = renderedPages[index]?.pageNumber ?? index + 1; + console.error(`Failed to detect fields on page ${pageNumber}:`, result.reason); + } + } + + if (env('NEXT_PUBLIC_AI_DEBUG_PREVIEW') === 'true') { + const debugDir = join(process.cwd(), '..', '..', 'packages', 'assets', 'ai-previews'); + await mkdir(debugDir, { recursive: true }); + + const now = new Date(); + const timestamp = now + .toISOString() + .replace(/[-:]/g, '') + .replace(/\..+/, '') + .replace('T', '_'); + + for (const page of renderedPages) { + const padding = { left: 80, top: 20, right: 20, bottom: 40 }; + const canvas = new Canvas( + page.width + padding.left + padding.right, + page.height + padding.top + padding.bottom, + ); + const ctx = canvas.getContext('2d'); + + const img = new Image(); + img.src = page.image; + ctx.drawImage(img, padding.left, padding.top); + + ctx.strokeStyle = 'rgba(255, 0, 0, 0.5)'; + ctx.lineWidth = 1; + + for (let i = 0; i <= 1000; i += 100) { + const x = padding.left + (i / 1000) * page.width; + ctx.beginPath(); + ctx.moveTo(x, padding.top); + ctx.lineTo(x, page.height + padding.top); + ctx.stroke(); + } + + for (let i = 0; i <= 1000; i += 100) { + const y = padding.top + (i / 1000) * page.height; + ctx.beginPath(); + ctx.moveTo(padding.left, y); + ctx.lineTo(page.width + padding.left, y); + ctx.stroke(); + } + + const colors = ['#FF0000', '#00FF00', '#0000FF', '#FFFF00', '#FF00FF', '#00FFFF']; + + const pageFields = detectedFields.filter((f) => f.pageNumber === page.pageNumber); + pageFields.forEach((field, index) => { + const [ymin, xmin, ymax, xmax] = field.boundingBox.map((coord) => coord / 1000); + + const x = xmin * page.width + padding.left; + const y = ymin * page.height + padding.top; + const width = (xmax - xmin) * page.width; + const height = (ymax - ymin) * page.height; + + ctx.strokeStyle = colors[index % colors.length]; + ctx.lineWidth = 5; + ctx.strokeRect(x, y, width, height); + + ctx.fillStyle = colors[index % colors.length]; + ctx.font = '20px Arial'; + ctx.fillText(field.label, x, y - 5); + }); + + ctx.strokeStyle = '#000000'; + ctx.lineWidth = 1; + ctx.font = '26px Arial'; + + ctx.beginPath(); + ctx.moveTo(padding.left, padding.top); + ctx.lineTo(padding.left, page.height + padding.top); + ctx.stroke(); + + ctx.textAlign = 'right'; + ctx.textBaseline = 'middle'; + for (let i = 0; i <= 1000; i += 100) { + const y = padding.top + (i / 1000) * page.height; + ctx.fillStyle = '#000000'; + ctx.fillText(i.toString(), padding.left - 5, y); + + ctx.beginPath(); + ctx.moveTo(padding.left - 5, y); + ctx.lineTo(padding.left, y); + ctx.stroke(); + } + + ctx.beginPath(); + ctx.moveTo(padding.left, page.height + padding.top); + ctx.lineTo(page.width + padding.left, page.height + padding.top); + ctx.stroke(); + + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + for (let i = 0; i <= 1000; i += 100) { + const x = padding.left + (i / 1000) * page.width; + ctx.fillStyle = '#000000'; + ctx.fillText(i.toString(), x, page.height + padding.top + 5); + + ctx.beginPath(); + ctx.moveTo(x, page.height + padding.top); + ctx.lineTo(x, page.height + padding.top + 5); + ctx.stroke(); + } + + const outputFilename = `detected_form_fields_${timestamp}_page_${page.pageNumber}.png`; + const outputPath = join(debugDir, outputFilename); + + const pngBuffer = await canvas.toBuffer('png'); + await writeFile(outputPath, new Uint8Array(pngBuffer)); + } + } + + return c.json(detectedFields); + } catch (error) { + if (error instanceof AppError) { + throw error; + } + + console.error('Failed to detect form fields from PDF:', error); + + throw new AppError(AppErrorCode.UNKNOWN_ERROR, { + message: `Failed to detect form fields from PDF: ${error instanceof Error ? error.message : String(error)}`, + userMessage: 'An error occurred while detecting form fields. Please try again.', + }); + } + }) + .post('/analyze-recipients', async (c) => { + try { + const { user } = await getSession(c.req.raw); + + const body = await c.req.json(); + const parsed = ZAnalyzeRecipientsRequestSchema.safeParse(body); + + if (!parsed.success) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: 'Envelope ID is required', + userMessage: 'Please provide a valid envelope ID.', + }); + } + + const { envelopeId } = parsed.data; + + // Use shared authorization function + const documentData = await authorizeDocumentAccess(envelopeId, user.id); + + const pdfBytes = await getFileServerSide({ + type: documentData.type, + data: documentData.initialData || documentData.data, + }); + + const renderedPages = await renderPdfToImage(pdfBytes); + + // Only analyze first few pages for performance + const pagesToAnalyze = renderedPages.slice(0, MAX_PAGES_FOR_RECIPIENT_ANALYSIS); + + const results = await Promise.allSettled( + pagesToAnalyze.map(async (page) => { + const compressedImageBuffer = await resizeAndCompressImage(page.image); + const base64Image = compressedImageBuffer.toString('base64'); + + const result = await generateObject({ + model: 'anthropic/claude-haiku-4.5', + output: 'array', + schema: ZDetectedRecipientLLMSchema, + messages: [ + { + role: 'user', + content: [ + { + type: 'image', + image: `data:image/jpeg;base64,${base64Image}`, + }, + { + type: 'text', + text: analyzeRecipientsPrompt, + }, + ], + }, + ], + }); + + console.info('AI analyze recipients raw response', { + envelopeId, + pageNumber: page.pageNumber, + recipients: result.object, + }); + + return { + pageNumber: page.pageNumber, + recipients: result.object, + }; + }), + ); + + const allRecipients: TDetectedRecipient[] = []; + let recipientIndex = 1; + + for (const result of results) { + if (result.status !== 'fulfilled') { + console.error('Failed to analyze recipients on a page:', result.reason); + continue; + } + + const { pageNumber, recipients } = result.value; + + const recipientsWithEmails = recipients.map((recipient) => { + const email = resolveRecipientEmail( + recipient.email, + recipient.name, + envelopeId, + recipientIndex, + ); + + const normalizedRecipient: TDetectedRecipient = { + ...recipient, + email, + }; + + recipientIndex += 1; + + return normalizedRecipient; + }); + + console.info('AI analyze recipients normalized response', { + envelopeId, + pageNumber, + recipients: recipientsWithEmails, + }); + + allRecipients.push(...recipientsWithEmails); + } + + return c.json(allRecipients); + } catch (error) { + if (error instanceof AppError) { + throw error; + } + + console.error('Failed to analyze recipients from PDF:', error); + + throw new AppError(AppErrorCode.UNKNOWN_ERROR, { + message: `Failed to analyze recipients from PDF: ${error instanceof Error ? error.message : String(error)}`, + userMessage: 'An error occurred while analyzing recipients. Please try again.', + }); + } + }); diff --git a/apps/remix/server/api/ai.types.ts b/apps/remix/server/api/ai.types.ts index 4f818f932..cfd581415 100644 --- a/apps/remix/server/api/ai.types.ts +++ b/apps/remix/server/api/ai.types.ts @@ -37,10 +37,16 @@ export const ZDetectedFormFieldSchema = z.object({ .int() .positive() .describe('1-indexed page number where field was detected'), + recipientId: z + .number() + .int() + .describe( + 'ID of the recipient (from the provided envelope recipients) who should own the field', + ), }); export const ZDetectFormFieldsRequestSchema = z.object({ - documentId: z.string().min(1, { message: 'Document ID is required' }), + envelopeId: z.string().min(1, { message: 'Envelope ID is required' }), }); export const ZDetectFormFieldsResponseSchema = z.array(ZDetectedFormFieldSchema); @@ -48,3 +54,48 @@ export const ZDetectFormFieldsResponseSchema = z.array(ZDetectedFormFieldSchema) export type TDetectFormFieldsRequest = z.infer; export type TDetectFormFieldsResponse = z.infer; export type { TDetectedFormField }; + +const recipientFieldShape = { + name: z.string().describe('Full name of the recipient'), + role: z.enum(['SIGNER', 'APPROVER', 'CC']).describe('Recipient role based on document context'), + signingOrder: z + .number() + .int() + .positive() + .optional() + .describe('Sequential signing order if document indicates ordering'), +} as const; + +const createRecipientSchema = (emailSchema: TSchema) => + z.object({ + ...recipientFieldShape, + email: emailSchema, + }); + +export const ZDetectedRecipientLLMSchema = createRecipientSchema( + z + .string() + .trim() + .max(320) + .optional() + .describe( + 'Email address from the document. If missing or invalid, a placeholder will be generated.', + ), +); + +export const ZDetectedRecipientSchema = createRecipientSchema( + z + .string() + .email() + .describe('Email address for the recipient (real, sample, or generated placeholder).'), +); + +export const ZAnalyzeRecipientsRequestSchema = z.object({ + envelopeId: z.string().min(1, { message: 'Envelope ID is required' }), +}); + +export const ZAnalyzeRecipientsResponseSchema = z.array(ZDetectedRecipientSchema); + +export type TDetectedRecipient = z.infer; +export type TAnalyzeRecipientsRequest = z.infer; +export type TAnalyzeRecipientsResponse = z.infer; diff --git a/packages/lib/server-only/recipient/create-envelope-recipients.ts b/packages/lib/server-only/recipient/create-envelope-recipients.ts index eaa5651f1..15a54431f 100644 --- a/packages/lib/server-only/recipient/create-envelope-recipients.ts +++ b/packages/lib/server-only/recipient/create-envelope-recipients.ts @@ -12,7 +12,7 @@ import { prisma } from '@documenso/prisma'; import { AppError, AppErrorCode } from '../../errors/app-error'; import type { EnvelopeIdOptions } from '../../utils/envelope'; -import { mapRecipientToLegacyRecipient } from '../../utils/recipients'; +import { mapRecipientToLegacyRecipient, sanitizeRecipientName } from '../../utils/recipients'; import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; export interface CreateEnvelopeRecipientsOptions { @@ -85,6 +85,7 @@ export const createEnvelopeRecipients = async ({ const normalizedRecipients = recipientsToCreate.map((recipient) => ({ ...recipient, + name: sanitizeRecipientName(recipient.name), email: recipient.email.toLowerCase(), })); diff --git a/packages/lib/server-only/recipient/set-document-recipients.ts b/packages/lib/server-only/recipient/set-document-recipients.ts index 6d185ded4..6d82f91ef 100644 --- a/packages/lib/server-only/recipient/set-document-recipients.ts +++ b/packages/lib/server-only/recipient/set-document-recipients.ts @@ -28,7 +28,7 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; import { AppError, AppErrorCode } from '../../errors/app-error'; import { extractDerivedDocumentEmailSettings } from '../../types/document-email'; import { type EnvelopeIdOptions, mapSecondaryIdToDocumentId } from '../../utils/envelope'; -import { canRecipientBeModified } from '../../utils/recipients'; +import { canRecipientBeModified, sanitizeRecipientName } from '../../utils/recipients'; import { renderEmailWithI18N } from '../../utils/render-email-with-i18n'; import { getEmailContext } from '../email/get-email-context'; import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; @@ -114,6 +114,7 @@ export const setDocumentRecipients = async ({ const normalizedRecipients = recipients.map((recipient) => ({ ...recipient, + name: sanitizeRecipientName(recipient.name), email: recipient.email.toLowerCase(), })); diff --git a/packages/lib/server-only/recipient/set-template-recipients.ts b/packages/lib/server-only/recipient/set-template-recipients.ts index 1743968c1..b3a439a23 100644 --- a/packages/lib/server-only/recipient/set-template-recipients.ts +++ b/packages/lib/server-only/recipient/set-template-recipients.ts @@ -15,6 +15,7 @@ import { import { nanoid } from '../../universal/id'; import { createRecipientAuthOptions } from '../../utils/document-auth'; import { type EnvelopeIdOptions, mapSecondaryIdToTemplateId } from '../../utils/envelope'; +import { sanitizeRecipientName } from '../../utils/recipients'; import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; export type SetTemplateRecipientsOptions = { @@ -88,6 +89,7 @@ export const setTemplateRecipients = async ({ return { ...recipient, + name: sanitizeRecipientName(recipient.name), email: recipient.email.toLowerCase(), }; }); diff --git a/packages/lib/server-only/recipient/update-envelope-recipients.ts b/packages/lib/server-only/recipient/update-envelope-recipients.ts index 92c365177..acfee9ca9 100644 --- a/packages/lib/server-only/recipient/update-envelope-recipients.ts +++ b/packages/lib/server-only/recipient/update-envelope-recipients.ts @@ -18,7 +18,7 @@ import { AppError, AppErrorCode } from '../../errors/app-error'; import { extractLegacyIds } from '../../universal/id'; import { type EnvelopeIdOptions } from '../../utils/envelope'; import { mapFieldToLegacyField } from '../../utils/fields'; -import { canRecipientBeModified } from '../../utils/recipients'; +import { canRecipientBeModified, sanitizeRecipientName } from '../../utils/recipients'; import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; export interface UpdateEnvelopeRecipientsOptions { @@ -108,9 +108,18 @@ export const updateEnvelopeRecipients = async ({ }); } + const sanitizedUpdateData = { + ...recipient, + ...(recipient.name !== undefined + ? { + name: sanitizeRecipientName(recipient.name), + } + : {}), + }; + return { originalRecipient, - updateData: recipient, + updateData: sanitizedUpdateData, }; }); diff --git a/packages/lib/types/ai.ts b/packages/lib/types/ai.ts index 230bbe373..d2ce79aca 100644 --- a/packages/lib/types/ai.ts +++ b/packages/lib/types/ai.ts @@ -12,4 +12,5 @@ export type TDetectedFormField = { | 'CHECKBOX' | 'DROPDOWN'; pageNumber: number; + recipientId: number; }; diff --git a/packages/lib/utils/logger.ts b/packages/lib/utils/logger.ts index 3b3878d5f..bdfd97a70 100644 --- a/packages/lib/utils/logger.ts +++ b/packages/lib/utils/logger.ts @@ -27,7 +27,7 @@ if (loggingFilePath) { } export const logger = pino({ - level: 'info', + level: env('LOG_LEVEL') || 'info', transport: transports.length > 0 ? { diff --git a/packages/lib/utils/recipients.ts b/packages/lib/utils/recipients.ts index 70411ad4d..4d14f5ac7 100644 --- a/packages/lib/utils/recipients.ts +++ b/packages/lib/utils/recipients.ts @@ -4,6 +4,8 @@ import { type Field, type Recipient, RecipientRole, SigningStatus } from '@prism import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app'; import { extractLegacyIds } from '../universal/id'; +const UNKNOWN_RECIPIENT_NAME_PLACEHOLDER = ''; + export const formatSigningLink = (token: string) => `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${token}`; /** @@ -58,3 +60,21 @@ export const mapRecipientToLegacyRecipient = ( ...legacyId, }; }; + +export const sanitizeRecipientName = (name?: string | null) => { + if (!name) { + return ''; + } + + const trimmedName = name.trim(); + + if (!trimmedName) { + return ''; + } + + if (trimmedName.toUpperCase() === UNKNOWN_RECIPIENT_NAME_PLACEHOLDER) { + return ''; + } + + return trimmedName; +};