chore: refactor

This commit is contained in:
Ephraim Atta-Duncan
2025-11-18 20:07:04 +00:00
parent 13bd5815d9
commit 8e2ca94020
13 changed files with 221 additions and 247 deletions

View File

@ -15,22 +15,22 @@ import {
DialogTitle,
} from '@documenso/ui/primitives/dialog';
type DocumentAiStep = 'PROMPT' | 'PROCESSING';
type RecipientDetectionStep = 'PROMPT' | 'PROCESSING';
export type DocumentAiPromptDialogProps = {
export type RecipientDetectionPromptDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
onAccept: () => Promise<void> | void;
onSkip: () => void;
};
export const DocumentAiPromptDialog = ({
export const RecipientDetectionPromptDialog = ({
open,
onOpenChange,
onAccept,
onSkip,
}: DocumentAiPromptDialogProps) => {
const [currentStep, setCurrentStep] = useState<DocumentAiStep>('PROMPT');
}: RecipientDetectionPromptDialogProps) => {
const [currentStep, setCurrentStep] = useState<RecipientDetectionStep>('PROMPT');
// Reset to first step when dialog closes
useEffect(() => {
@ -39,7 +39,7 @@ export const DocumentAiPromptDialog = ({
}
}, [open]);
const handleUseAi = () => {
const handleStartDetection = () => {
setCurrentStep('PROCESSING');
Promise.resolve(onAccept()).catch(() => {
@ -61,12 +61,12 @@ export const DocumentAiPromptDialog = ({
<>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Trans>Use AI to prepare your document?</Trans>
<Trans>Auto-detect recipients?</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.
Would you like to automatically detect recipients in your document? This can
save you time in setting up your document.
</Trans>
</DialogDescription>
</DialogHeader>
@ -75,8 +75,8 @@ export const DocumentAiPromptDialog = ({
<Button type="button" variant="secondary" onClick={handleSkip}>
<Trans>Skip for now</Trans>
</Button>
<Button type="button" onClick={handleUseAi}>
<Trans>Use AI</Trans>
<Button type="button" onClick={handleStartDetection}>
<Trans>Detect recipients</Trans>
</Button>
</DialogFooter>
</>
@ -90,8 +90,7 @@ export const DocumentAiPromptDialog = ({
</DialogTitle>
<DialogDescription className="text-center">
<Trans>
Our AI is scanning your document to detect recipient names, emails, and
signing order.
Scanning your document to detect recipient names, emails, and signing order.
</Trans>
</DialogDescription>
</DialogHeader>

View File

@ -34,9 +34,9 @@ import {
} from '@documenso/ui/primitives/form/form';
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
import type { RecipientForCreation } from '~/utils/analyze-ai-recipients';
import type { RecipientForCreation } from '~/utils/detect-document-recipients';
const ZDocumentAiRecipientSchema = z.object({
const ZSuggestedRecipientSchema = z.object({
formId: z.string().min(1),
name: z
.string()
@ -50,15 +50,15 @@ const ZDocumentAiRecipientSchema = z.object({
role: z.nativeEnum(RecipientRole),
});
const ZDocumentAiRecipientsForm = z.object({
const ZSuggestedRecipientsFormSchema = z.object({
recipients: z
.array(ZDocumentAiRecipientSchema)
.array(ZSuggestedRecipientSchema)
.min(1, { message: msg`Please add at least one recipient`.id }),
});
type TDocumentAiRecipientsForm = z.infer<typeof ZDocumentAiRecipientsForm>;
type TSuggestedRecipientsFormSchema = z.infer<typeof ZSuggestedRecipientsFormSchema>;
export type DocumentAiRecipientsDialogProps = {
export type SuggestedRecipientsDialogProps = {
open: boolean;
recipients: RecipientForCreation[] | null;
onOpenChange: (open: boolean) => void;
@ -66,13 +66,13 @@ export type DocumentAiRecipientsDialogProps = {
onSubmit: (recipients: RecipientForCreation[]) => Promise<void> | void;
};
export const DocumentAiRecipientsDialog = ({
export const SuggestedRecipientsDialog = ({
open,
recipients,
onOpenChange,
onCancel,
onSubmit,
}: DocumentAiRecipientsDialogProps) => {
}: SuggestedRecipientsDialogProps) => {
const { t } = useLingui();
const [recipientSearchQuery, setRecipientSearchQuery] = useState('');
@ -117,8 +117,8 @@ export const DocumentAiRecipientsDialog = ({
];
}, [recipients]);
const form = useForm<TDocumentAiRecipientsForm>({
resolver: zodResolver(ZDocumentAiRecipientsForm),
const form = useForm<TSuggestedRecipientsFormSchema>({
resolver: zodResolver(ZSuggestedRecipientsFormSchema),
defaultValues: {
recipients: defaultRecipients,
},

View File

@ -54,30 +54,7 @@ const EnvelopeEditorFieldsPageRenderer = lazy(
async () => import('./envelope-editor-fields-page-renderer'),
);
/**
* Enforces minimum field dimensions and centers the field when expanding to meet minimums.
*
* AI often detects form lines as very thin fields (0.2-0.5% height). This function ensures
* fields meet minimum usability requirements by expanding them to at least 30px height and
* 36px width, while keeping them centered on their original position.
*
* @param params - Field dimensions and page size
* @param params.positionX - Field X position as percentage (0-100)
* @param params.positionY - Field Y position as percentage (0-100)
* @param params.width - Field width as percentage (0-100)
* @param params.height - Field height as percentage (0-100)
* @param params.pageWidth - Page width in pixels
* @param params.pageHeight - Page height in pixels
* @returns Adjusted field dimensions with minimums enforced and centered
*
* @example
* // AI detected a thin line: 0.3% height
* const adjusted = enforceMinimumFieldDimensions({
* positionX: 20, positionY: 50, width: 30, height: 0.3,
* pageWidth: 800, pageHeight: 1100
* });
* // Result: height expanded to ~2.7% (30px), centered on original position
*/
// Expands fields to minimum usable dimensions (30px height, 36px width) and centers them
const enforceMinimumFieldDimensions = (params: {
positionX: number;
positionY: number;
@ -94,7 +71,6 @@ const enforceMinimumFieldDimensions = (params: {
const MIN_HEIGHT_PX = 30;
const MIN_WIDTH_PX = 36;
// Convert percentage to pixels to check against minimums
const widthPx = (params.width / 100) * params.pageWidth;
const heightPx = (params.height / 100) * params.pageHeight;
@ -136,7 +112,7 @@ const enforceMinimumFieldDimensions = (params: {
};
};
const processAllPagesWithAI = async (params: {
const detectFormFieldsInDocument = async (params: {
envelopeId: string;
onProgress: (current: number, total: number) => void;
}): Promise<{
@ -148,10 +124,9 @@ const processAllPagesWithAI = async (params: {
const errors = new Map<number, Error>();
try {
// Make single API call to process all pages server-side
onProgress(0, 1);
const response = await fetch('/api/ai/detect-form-fields', {
const response = await fetch('/api/ai/detect-fields', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@ -162,12 +137,11 @@ const processAllPagesWithAI = async (params: {
if (!response.ok) {
const errorText = await response.text();
throw new Error(`AI detection failed: ${response.statusText} - ${errorText}`);
throw new Error(`Field detection failed: ${response.statusText} - ${errorText}`);
}
const detectedFields: TDetectedFormField[] = await response.json();
// Group fields by page number
for (const field of detectedFields) {
if (!fieldsPerPage.has(field.pageNumber)) {
fieldsPerPage.set(field.pageNumber, []);
@ -177,7 +151,6 @@ const processAllPagesWithAI = async (params: {
onProgress(1, 1);
} catch (error) {
// If request fails, treat it as error for all pages
errors.set(0, error instanceof Error ? error : new Error(String(error)));
}
@ -206,7 +179,7 @@ export const EnvelopeEditorFieldsPage = () => {
const { t } = useLingui();
const { toast } = useToast();
const [isAutoAddingFields, setIsAutoAddingFields] = useState(false);
const [isDetectingFields, setIsAutoAddingFields] = useState(false);
const [processingProgress, setProcessingProgress] = useState<{
current: number;
total: number;
@ -224,14 +197,10 @@ export const EnvelopeEditorFieldsPage = () => {
const isMetaSame = isDeepEqual(selectedField.fieldMeta, fieldMeta);
// Todo: Envelopes - Clean up console logs.
if (!isMetaSame) {
console.log('TRIGGER UPDATE');
editorFields.updateFieldByFormId(selectedField.formId, {
fieldMeta,
});
} else {
console.log('DATA IS SAME, NO UPDATE');
}
};
@ -251,7 +220,7 @@ export const EnvelopeEditorFieldsPage = () => {
<div className="relative flex h-full">
<div className="relative flex w-full flex-col overflow-y-auto">
{/* Horizontal envelope item selector */}
{isAutoAddingFields && (
{isDetectingFields && (
<>
<div className="edge-glow edge-glow-top pointer-events-none fixed left-0 right-0 top-0 z-20 h-16" />
<div className="edge-glow edge-glow-right pointer-events-none fixed bottom-0 right-0 top-0 z-20 w-16" />
@ -353,7 +322,7 @@ export const EnvelopeEditorFieldsPage = () => {
<Button
className="mt-4 w-full"
variant="outline"
disabled={isAutoAddingFields}
disabled={isDetectingFields}
onClick={async () => {
setIsAutoAddingFields(true);
setProcessingProgress(null);
@ -377,7 +346,7 @@ export const EnvelopeEditorFieldsPage = () => {
return;
}
const { fieldsPerPage, errors } = await processAllPagesWithAI({
const { fieldsPerPage, errors } = await detectFormFieldsInDocument({
envelopeId: envelope.id,
onProgress: (current, total) => {
setProcessingProgress({ current, total });
@ -488,7 +457,7 @@ export const EnvelopeEditorFieldsPage = () => {
}
}}
>
{isAutoAddingFields ? <Trans>Processing...</Trans> : <Trans>Auto add fields</Trans>}
{isDetectingFields ? <Trans>Processing...</Trans> : <Trans>Auto add fields</Trans>}
</Button>
</section>

View File

@ -1,7 +1,6 @@
import { type ReactNode, useState } from 'react';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import { Loader } from 'lucide-react';
import {
@ -27,14 +26,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 { RecipientDetectionPromptDialog } from '~/components/dialogs/recipient-detection-prompt-dialog';
import { SuggestedRecipientsDialog } from '~/components/dialogs/suggested-recipients-dialog';
import { useCurrentTeam } from '~/providers/team';
import {
type RecipientForCreation,
analyzeRecipientsFromDocument,
detectRecipientsInDocument,
ensureRecipientEmails,
} from '~/utils/analyze-ai-recipients';
} from '~/utils/detect-document-recipients';
export interface EnvelopeDropZoneWrapperProps {
children: ReactNode;
@ -59,10 +58,10 @@ export const EnvelopeDropZoneWrapper = ({
const organisation = useCurrentOrganisation();
const [isLoading, setIsLoading] = useState(false);
const [showAiPromptDialog, setShowAiPromptDialog] = useState(false);
const [showRecipientDetectionPrompt, setShowRecipientDetectionPrompt] = useState(false);
const [uploadedDocumentId, setUploadedDocumentId] = useState<string | null>(null);
const [pendingRecipients, setPendingRecipients] = useState<RecipientForCreation[] | null>(null);
const [showAiRecipientsDialog, setShowAiRecipientsDialog] = useState(false);
const [showSuggestedRecipientsDialog, setShowSuggestedRecipientsDialog] = useState(false);
const [shouldNavigateAfterPromptClose, setShouldNavigateAfterPromptClose] = useState(true);
const userTimezone =
@ -125,9 +124,9 @@ export const EnvelopeDropZoneWrapper = ({
// Show AI prompt dialog for documents
setUploadedDocumentId(id);
setPendingRecipients(null);
setShowAiRecipientsDialog(false);
setShowSuggestedRecipientsDialog(false);
setShouldNavigateAfterPromptClose(true);
setShowAiPromptDialog(true);
setShowRecipientDetectionPrompt(true);
} else {
// Templates - navigate immediately
const pathPrefix = formatTemplatesPath(team.url);
@ -228,13 +227,13 @@ export const EnvelopeDropZoneWrapper = ({
void navigate(`${pathPrefix}/${uploadedDocumentId}/edit`);
};
const handleAiAccept = async () => {
const handleStartRecipientDetection = async () => {
if (!uploadedDocumentId) {
return;
}
try {
const recipients = await analyzeRecipientsFromDocument(uploadedDocumentId);
const recipients = await detectRecipientsInDocument(uploadedDocumentId);
if (recipients.length === 0) {
toast({
@ -250,14 +249,14 @@ export const EnvelopeDropZoneWrapper = ({
setPendingRecipients(recipientsWithEmails);
setShouldNavigateAfterPromptClose(false);
setShowAiPromptDialog(false);
setShowAiRecipientsDialog(true);
setShowRecipientDetectionPrompt(false);
setShowSuggestedRecipientsDialog(true);
} catch (error) {
if (!(error instanceof Error && error.message === 'NO_RECIPIENTS_DETECTED')) {
const parsedError = AppError.parseError(error);
toast({
title: t`Failed to analyze recipients`,
title: t`Failed to detect recipients`,
description: parsedError.userMessage || t`You can add recipients manually in the editor`,
variant: 'destructive',
duration: 7500,
@ -268,14 +267,14 @@ export const EnvelopeDropZoneWrapper = ({
}
};
const handleAiSkip = () => {
const handleSkipRecipientDetection = () => {
setShouldNavigateAfterPromptClose(true);
setShowAiPromptDialog(false);
setShowRecipientDetectionPrompt(false);
navigateToEnvelopeEditor();
};
const handleRecipientsCancel = () => {
setShowAiRecipientsDialog(false);
setShowSuggestedRecipientsDialog(false);
setPendingRecipients(null);
navigateToEnvelopeEditor();
};
@ -297,7 +296,7 @@ export const EnvelopeDropZoneWrapper = ({
duration: 5000,
});
setShowAiRecipientsDialog(false);
setShowSuggestedRecipientsDialog(false);
setPendingRecipients(null);
navigateToEnvelopeEditor();
} catch (error) {
@ -315,7 +314,7 @@ export const EnvelopeDropZoneWrapper = ({
};
const handlePromptDialogOpenChange = (open: boolean) => {
setShowAiPromptDialog(open);
setShowRecipientDetectionPrompt(open);
if (open) {
setShouldNavigateAfterPromptClose(true);
@ -394,21 +393,21 @@ export const EnvelopeDropZoneWrapper = ({
</div>
)}
<DocumentAiPromptDialog
open={showAiPromptDialog}
<RecipientDetectionPromptDialog
open={showRecipientDetectionPrompt}
onOpenChange={handlePromptDialogOpenChange}
onAccept={handleAiAccept}
onSkip={handleAiSkip}
onAccept={handleStartRecipientDetection}
onSkip={handleSkipRecipientDetection}
/>
<DocumentAiRecipientsDialog
open={showAiRecipientsDialog}
<SuggestedRecipientsDialog
open={showSuggestedRecipientsDialog}
recipients={pendingRecipients}
onOpenChange={(open) => {
if (!open) {
handleRecipientsCancel();
} else {
setShowAiRecipientsDialog(true);
setShowSuggestedRecipientsDialog(true);
}
}}
onCancel={handleRecipientsCancel}

View File

@ -27,14 +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 { RecipientDetectionPromptDialog } from '~/components/dialogs/recipient-detection-prompt-dialog';
import { SuggestedRecipientsDialog } from '~/components/dialogs/suggested-recipients-dialog';
import { useCurrentTeam } from '~/providers/team';
import {
type RecipientForCreation,
analyzeRecipientsFromDocument,
detectRecipientsInDocument,
ensureRecipientEmails,
} from '~/utils/analyze-ai-recipients';
} from '~/utils/detect-document-recipients';
export type EnvelopeUploadButtonProps = {
className?: string;
@ -62,10 +62,10 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
const { quota, remaining, refreshLimits, maximumEnvelopeItemCount } = useLimits();
const [isLoading, setIsLoading] = useState(false);
const [showAiPromptDialog, setShowAiPromptDialog] = useState(false);
const [showRecipientDetectionPrompt, setShowAiPromptDialog] = useState(false);
const [uploadedDocumentId, setUploadedDocumentId] = useState<string | null>(null);
const [pendingRecipients, setPendingRecipients] = useState<RecipientForCreation[] | null>(null);
const [showAiRecipientsDialog, setShowAiRecipientsDialog] = useState(false);
const [showSuggestedRecipientsDialog, setShowAiRecipientsDialog] = useState(false);
const [shouldNavigateAfterPromptClose, setShouldNavigateAfterPromptClose] = useState(true);
const { mutateAsync: createEnvelope } = trpc.envelope.create.useMutation();
@ -204,13 +204,13 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
void navigate(`${pathPrefix}/${uploadedDocumentId}/edit`);
};
const handleAiAccept = async () => {
const handleStartRecipientDetection = async () => {
if (!uploadedDocumentId) {
return;
}
try {
const recipients = await analyzeRecipientsFromDocument(uploadedDocumentId);
const recipients = await detectRecipientsInDocument(uploadedDocumentId);
if (recipients.length === 0) {
toast({
@ -244,7 +244,7 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
}
};
const handleAiSkip = () => {
const handleSkipRecipientDetection = () => {
setShouldNavigateAfterPromptClose(true);
setShowAiPromptDialog(false);
navigateToEnvelopeEditor();
@ -336,15 +336,15 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
</Tooltip>
</TooltipProvider>
<DocumentAiPromptDialog
open={showAiPromptDialog}
<RecipientDetectionPromptDialog
open={showRecipientDetectionPrompt}
onOpenChange={handlePromptDialogOpenChange}
onAccept={handleAiAccept}
onSkip={handleAiSkip}
onAccept={handleStartRecipientDetection}
onSkip={handleSkipRecipientDetection}
/>
<DocumentAiRecipientsDialog
open={showAiRecipientsDialog}
<SuggestedRecipientsDialog
open={showSuggestedRecipientsDialog}
recipients={pendingRecipients}
onOpenChange={(open) => {
if (!open) {

View File

@ -1,17 +1,19 @@
import { RecipientRole } from '@prisma/client';
import { AppError } from '@documenso/lib/errors/app-error';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
export type AiRecipient = {
export type SuggestedRecipient = {
name: string;
email?: string;
role: 'SIGNER' | 'APPROVER' | 'CC';
signingOrder?: number;
};
export const analyzeRecipientsFromDocument = async (envelopeId: string): Promise<AiRecipient[]> => {
export const detectRecipientsInDocument = async (
envelopeId: string,
): Promise<SuggestedRecipient[]> => {
try {
const response = await fetch('/api/ai/analyze-recipients', {
const response = await fetch('/api/ai/detect-recipients', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@ -20,10 +22,12 @@ export const analyzeRecipientsFromDocument = async (envelopeId: string): Promise
});
if (!response.ok) {
throw new Error('Failed to analyze recipients');
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Failed to detect recipients',
});
}
return (await response.json()) as AiRecipient[];
return (await response.json()) as SuggestedRecipient[];
} catch (error) {
throw AppError.parseError(error);
}
@ -37,7 +41,7 @@ export type RecipientForCreation = {
};
export const ensureRecipientEmails = (
recipients: AiRecipient[],
recipients: SuggestedRecipient[],
envelopeId: string,
): RecipientForCreation[] => {
const allowedRoles: RecipientRole[] = [