mirror of
https://github.com/documenso/documenso.git
synced 2025-11-19 19:21:39 +10:00
Compare commits
19 Commits
perf/insig
...
exp/autopl
| Author | SHA1 | Date | |
|---|---|---|---|
| 57c4c3fd48 | |||
| eb63ec436c | |||
| d6a1ce06d4 | |||
| 08e1dcaa76 | |||
| 92ec5e8ee4 | |||
| 548a74ab89 | |||
| c8e254aff1 | |||
| 8e2ca94020 | |||
| 13bd5815d9 | |||
| dbed8b362e | |||
| 9e0f07f806 | |||
| 5fbad9e367 | |||
| ac4b3737d6 | |||
| cdfd373958 | |||
| 233e6e603c | |||
| 134d5ac03e | |||
| 00e33c5331 | |||
| 29be66a844 | |||
| 94098bd762 |
@ -135,6 +135,14 @@ NEXT_PUBLIC_FEATURE_BILLING_ENABLED=
|
||||
# OPTIONAL: Leave blank to allow users to signup through /signup page.
|
||||
NEXT_PUBLIC_DISABLE_SIGNUP=
|
||||
|
||||
# [[AI]]
|
||||
# AI Gateway
|
||||
AI_GATEWAY_API_KEY=""
|
||||
# OPTIONAL: API key for Google Generative AI (Gemini). Get your key from https://ai.google.dev
|
||||
GOOGLE_GENERATIVE_AI_API_KEY=""
|
||||
# OPTIONAL: Enable AI field detection debug mode to save preview images with bounding boxes
|
||||
NEXT_PUBLIC_AI_DEBUG_PREVIEW=
|
||||
|
||||
# [[E2E Tests]]
|
||||
E2E_TEST_AUTHENTICATE_USERNAME="Test User"
|
||||
E2E_TEST_AUTHENTICATE_USER_EMAIL="testuser@mail.com"
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -60,3 +60,6 @@ CLAUDE.md
|
||||
|
||||
# agents
|
||||
.specs
|
||||
|
||||
# ai debug previews
|
||||
packages/assets/ai-previews/
|
||||
|
||||
@ -69,3 +69,4 @@
|
||||
--font-noto: 'Noto Sans', 'Noto Sans Korean', 'Noto Sans Japanese', 'Noto Sans Chinese';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -72,7 +72,6 @@ export const OrganisationEmailCreateDialog = ({
|
||||
const { mutateAsync: createOrganisationEmail, isPending } =
|
||||
trpc.enterprise.organisation.email.create.useMutation();
|
||||
|
||||
// Reset state when dialog closes
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
form.reset();
|
||||
|
||||
@ -0,0 +1,251 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
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';
|
||||
|
||||
import type { RecipientForCreation } from '~/utils/detect-document-recipients';
|
||||
|
||||
import { SuggestedRecipientsForm } from './suggested-recipients-form';
|
||||
|
||||
type RecipientDetectionStep =
|
||||
| 'PROMPT_DETECT_RECIPIENTS'
|
||||
| 'DETECTING_RECIPIENTS'
|
||||
| 'REVIEW_RECIPIENTS'
|
||||
| 'DETECTING_FIELDS';
|
||||
|
||||
export type RecipientDetectionPromptDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onAccept: () => Promise<void> | void;
|
||||
onSkip: () => void;
|
||||
recipients: RecipientForCreation[] | null;
|
||||
onRecipientsSubmit: (recipients: RecipientForCreation[]) => Promise<void> | void;
|
||||
onAutoAddFields?: (recipients: RecipientForCreation[]) => Promise<void> | void;
|
||||
isProcessingRecipients?: boolean;
|
||||
};
|
||||
|
||||
export const RecipientDetectionPromptDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
onAccept,
|
||||
onSkip,
|
||||
recipients,
|
||||
onRecipientsSubmit,
|
||||
onAutoAddFields,
|
||||
isProcessingRecipients = false,
|
||||
}: RecipientDetectionPromptDialogProps) => {
|
||||
const [currentStep, setCurrentStep] = useState<RecipientDetectionStep>(
|
||||
'PROMPT_DETECT_RECIPIENTS',
|
||||
);
|
||||
const [currentRecipients, setCurrentRecipients] = useState<RecipientForCreation[] | null>(
|
||||
recipients,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setCurrentStep('PROMPT_DETECT_RECIPIENTS');
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentRecipients(recipients);
|
||||
}, [recipients]);
|
||||
|
||||
useEffect(() => {
|
||||
if (recipients && currentStep === 'DETECTING_RECIPIENTS') {
|
||||
setCurrentStep('REVIEW_RECIPIENTS');
|
||||
}
|
||||
}, [recipients, currentStep]);
|
||||
|
||||
const handleStartDetection = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setCurrentStep('DETECTING_RECIPIENTS');
|
||||
|
||||
Promise.resolve(onAccept()).catch(() => {
|
||||
setCurrentStep('PROMPT_DETECT_RECIPIENTS');
|
||||
});
|
||||
};
|
||||
|
||||
const handleSkip = () => {
|
||||
onSkip();
|
||||
};
|
||||
|
||||
const handleAutoAddFields = async (recipients: RecipientForCreation[]) => {
|
||||
if (!onAutoAddFields) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Save the current state of recipients so if we fail and come back,
|
||||
// the form is restored with the user's changes.
|
||||
setCurrentRecipients(recipients);
|
||||
setCurrentStep('DETECTING_FIELDS');
|
||||
|
||||
try {
|
||||
await onAutoAddFields(recipients);
|
||||
} catch {
|
||||
setCurrentStep('REVIEW_RECIPIENTS');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(newOpen) => {
|
||||
// Prevent closing during processing
|
||||
if (
|
||||
!newOpen &&
|
||||
(currentStep === 'DETECTING_RECIPIENTS' ||
|
||||
currentStep === 'DETECTING_FIELDS' ||
|
||||
isProcessingRecipients)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
onOpenChange(newOpen);
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className={
|
||||
currentStep === 'REVIEW_RECIPIENTS' ? 'max-h-[90vh] max-w-4xl overflow-y-auto' : ''
|
||||
}
|
||||
>
|
||||
<fieldset
|
||||
disabled={currentStep === 'DETECTING_RECIPIENTS' || currentStep === 'DETECTING_FIELDS'}
|
||||
>
|
||||
<AnimateGenericFadeInOut motionKey={currentStep} className="grid gap-4">
|
||||
{match(currentStep)
|
||||
.with('PROMPT_DETECT_RECIPIENTS', () => (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Auto-detect recipients?</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
Would you like to automatically detect recipients in your document? This can
|
||||
save you time in setting up your document.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={handleSkip}>
|
||||
<Trans>Skip for now</Trans>
|
||||
</Button>
|
||||
<Button onClick={handleStartDetection}>
|
||||
<Trans>Detect recipients</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
))
|
||||
.with('DETECTING_RECIPIENTS', () => (
|
||||
<div className="flex flex-col items-center justify-center py-4">
|
||||
<div className="relative mb-4 flex items-center justify-center">
|
||||
<div
|
||||
className="border-muted-foreground/20 dark:bg-muted/80 z-10 flex aspect-[3/4] w-24 origin-top-left flex-col gap-y-1 overflow-hidden rounded-lg border px-2 py-4 backdrop-blur-sm"
|
||||
style={{ transform: 'translateZ(0px)' }}
|
||||
>
|
||||
<div className="bg-muted-foreground/20 h-2 w-full rounded-[2px]"></div>
|
||||
<div className="bg-muted-foreground/20 h-2 w-5/6 rounded-[2px]"></div>
|
||||
<div className="bg-muted-foreground/20 h-2 w-full rounded-[2px]"></div>
|
||||
|
||||
<div className="bg-muted-foreground/20 h-2 w-4/5 rounded-[2px]"></div>
|
||||
<div className="bg-muted-foreground/20 h-2 w-full rounded-[2px]"></div>
|
||||
<div className="bg-muted-foreground/20 h-2 w-3/4 rounded-[2px]"></div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="bg-documenso/80 animate-scan pointer-events-none absolute left-1/2 top-0 z-20 h-0.5 w-24 -translate-x-1/2"
|
||||
style={{
|
||||
transform: 'translateX(-50%) translateZ(0px)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-center">
|
||||
<Trans>Analyzing your document</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-center">
|
||||
<Trans>
|
||||
Scanning your document to detect recipient names, emails, and signing order.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
))
|
||||
.with('DETECTING_FIELDS', () => (
|
||||
<div className="flex flex-col items-center justify-center py-4">
|
||||
<div className="relative mb-4 flex items-center justify-center">
|
||||
<div
|
||||
className="border-muted-foreground/20 dark:bg-muted/80 z-10 flex aspect-[3/4] w-24 origin-top-left flex-col gap-y-1 overflow-hidden rounded-lg border px-2 py-4 backdrop-blur-sm"
|
||||
style={{ transform: 'translateZ(0px)' }}
|
||||
>
|
||||
<div className="bg-muted-foreground/20 h-2 w-full rounded-[2px]"></div>
|
||||
<div className="bg-muted-foreground/20 h-2 w-5/6 rounded-[2px]"></div>
|
||||
<div className="bg-muted-foreground/20 h-2 w-full rounded-[2px]"></div>
|
||||
|
||||
<div className="bg-muted-foreground/20 h-2 w-4/5 rounded-[2px]"></div>
|
||||
<div className="bg-muted-foreground/20 h-2 w-full rounded-[2px]"></div>
|
||||
<div className="bg-muted-foreground/20 h-2 w-3/4 rounded-[2px]"></div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="bg-documenso/80 animate-scan pointer-events-none absolute left-1/2 top-0 z-20 h-0.5 w-24 -translate-x-1/2"
|
||||
style={{
|
||||
transform: 'translateX(-50%) translateZ(0px)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-center">
|
||||
<Trans>Detecting fields</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-center">
|
||||
<Trans>
|
||||
Scanning your document to intelligently place fields for your recipients.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
))
|
||||
.with('REVIEW_RECIPIENTS', () => (
|
||||
<>
|
||||
<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>
|
||||
<SuggestedRecipientsForm
|
||||
recipients={currentRecipients}
|
||||
onCancel={handleSkip}
|
||||
onSubmit={onRecipientsSubmit}
|
||||
onAutoAddFields={onAutoAddFields ? handleAutoAddFields : undefined}
|
||||
isProcessing={isProcessingRecipients}
|
||||
/>
|
||||
</>
|
||||
))
|
||||
.exhaustive()}
|
||||
</AnimateGenericFadeInOut>
|
||||
</fieldset>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
408
apps/remix/app/components/dialogs/suggested-recipients-form.tsx
Normal file
408
apps/remix/app/components/dialogs/suggested-recipients-form.tsx
Normal file
@ -0,0 +1,408 @@
|
||||
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 { DialogFooter } 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 {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@documenso/ui/primitives/tooltip';
|
||||
|
||||
import type { RecipientForCreation } from '~/utils/detect-document-recipients';
|
||||
|
||||
const ZSuggestedRecipientSchema = 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 ZSuggestedRecipientsFormSchema = z.object({
|
||||
recipients: z
|
||||
.array(ZSuggestedRecipientSchema)
|
||||
.min(1, { message: msg`Please add at least one recipient`.id }),
|
||||
});
|
||||
|
||||
type TSuggestedRecipientsFormSchema = z.infer<typeof ZSuggestedRecipientsFormSchema>;
|
||||
|
||||
export type SuggestedRecipientsFormProps = {
|
||||
recipients: RecipientForCreation[] | null;
|
||||
onCancel: () => void;
|
||||
onSubmit: (recipients: RecipientForCreation[]) => Promise<void> | void;
|
||||
onAutoAddFields?: (recipients: RecipientForCreation[]) => Promise<void> | void;
|
||||
isProcessing?: boolean;
|
||||
};
|
||||
|
||||
export const SuggestedRecipientsForm = ({
|
||||
recipients,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
onAutoAddFields,
|
||||
isProcessing = false,
|
||||
}: SuggestedRecipientsFormProps) => {
|
||||
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<TSuggestedRecipientsFormSchema>({
|
||||
resolver: zodResolver(ZSuggestedRecipientsFormSchema),
|
||||
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 (error) {
|
||||
console.error('Failed to submit recipients:', error);
|
||||
}
|
||||
});
|
||||
|
||||
const handleAutoAddFields = form.handleSubmit(async (values) => {
|
||||
if (!onAutoAddFields) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedRecipients: RecipientForCreation[] = values.recipients.map(
|
||||
(recipient, index) => ({
|
||||
name: recipient.name.trim(),
|
||||
email: recipient.email.trim(),
|
||||
role: recipient.role,
|
||||
signingOrder: index + 1,
|
||||
}),
|
||||
);
|
||||
|
||||
try {
|
||||
await onAutoAddFields(normalizedRecipients);
|
||||
} catch (error) {
|
||||
console.error('Failed to auto-add fields:', error);
|
||||
}
|
||||
});
|
||||
|
||||
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 (
|
||||
<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-between sm:gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onCancel}
|
||||
disabled={isSubmitting || isProcessing}
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:gap-3">
|
||||
<Button type="submit" disabled={isSubmitting || isProcessing}>
|
||||
<Trans>Use recipients</Trans>
|
||||
</Button>
|
||||
|
||||
{onAutoAddFields && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleAutoAddFields}
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
isProcessing ||
|
||||
fields.length === 0 ||
|
||||
!form.formState.isValid
|
||||
}
|
||||
>
|
||||
{isProcessing ? (
|
||||
<Trans>Processing...</Trans>
|
||||
) : (
|
||||
<Trans>Auto add fields</Trans>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{(fields.length === 0 || !form.formState.isValid) && (
|
||||
<TooltipContent>
|
||||
<Trans>Please add at least one valid recipient to auto-detect fields</Trans>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@ -29,7 +29,6 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitive
|
||||
import { useConfigureDocument } from './configure-document-context';
|
||||
import type { TConfigureEmbedFormSchema } from './configure-document-view.types';
|
||||
|
||||
// Define a type for signer items
|
||||
type SignerItem = TConfigureEmbedFormSchema['signers'][number];
|
||||
|
||||
export interface ConfigureDocumentRecipientsProps {
|
||||
@ -97,18 +96,15 @@ export const ConfigureDocumentRecipients = ({
|
||||
const currentSigners = getValues('signers') || [...signers];
|
||||
const signer = currentSigners[index];
|
||||
|
||||
// Remove signer from current position and insert at new position
|
||||
const remainingSigners = currentSigners.filter((_: unknown, idx: number) => idx !== index);
|
||||
const newPosition = Math.min(Math.max(0, newOrder - 1), currentSigners.length - 1);
|
||||
remainingSigners.splice(newPosition, 0, signer);
|
||||
|
||||
// Update signing order for each item
|
||||
const updatedSigners = remainingSigners.map((s: SignerItem, idx: number) => ({
|
||||
...s,
|
||||
signingOrder: signingOrder === DocumentSigningOrder.SEQUENTIAL ? idx + 1 : undefined,
|
||||
}));
|
||||
|
||||
// Update the form
|
||||
replace(updatedSigners);
|
||||
},
|
||||
[signers, replace, getValues],
|
||||
@ -118,17 +114,14 @@ export const ConfigureDocumentRecipients = ({
|
||||
(result: DropResult) => {
|
||||
if (!result.destination) return;
|
||||
|
||||
// Use the move function from useFieldArray which preserves input values
|
||||
move(result.source.index, result.destination.index);
|
||||
|
||||
// Update signing orders after move
|
||||
const currentSigners = getValues('signers');
|
||||
const updatedSigners = currentSigners.map((signer: SignerItem, index: number) => ({
|
||||
...signer,
|
||||
signingOrder: signingOrder === DocumentSigningOrder.SEQUENTIAL ? index + 1 : undefined,
|
||||
}));
|
||||
|
||||
// Update the form with new ordering
|
||||
replace(updatedSigners);
|
||||
},
|
||||
[move, replace, getValues],
|
||||
|
||||
@ -174,18 +174,12 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
* Initialize the Konva page canvas and all fields and interactions.
|
||||
*/
|
||||
const createPageCanvas = (currentStage: Konva.Stage, currentPageLayer: Konva.Layer) => {
|
||||
// Initialize snap guides layer
|
||||
// snapGuideLayer.current = initializeSnapGuides(stage.current);
|
||||
|
||||
// Add transformer for resizing and rotating.
|
||||
interactiveTransformer.current = createInteractiveTransformer(currentStage, currentPageLayer);
|
||||
|
||||
// Render the fields.
|
||||
for (const field of localPageFields) {
|
||||
renderFieldOnLayer(field);
|
||||
}
|
||||
|
||||
// Handle stage click to deselect.
|
||||
currentStage.on('mousedown', (e) => {
|
||||
removePendingField();
|
||||
|
||||
@ -270,7 +264,6 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
let y2: number;
|
||||
|
||||
currentStage.on('mousedown touchstart', (e) => {
|
||||
// do nothing if we mousedown on any shape
|
||||
if (e.target !== currentStage) {
|
||||
return;
|
||||
}
|
||||
@ -296,7 +289,6 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
});
|
||||
|
||||
currentStage.on('mousemove touchmove', () => {
|
||||
// do nothing if we didn't start selection
|
||||
if (!selectionRectangle.visible()) {
|
||||
return;
|
||||
}
|
||||
@ -321,7 +313,6 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
});
|
||||
|
||||
currentStage.on('mouseup touchend', () => {
|
||||
// do nothing if we didn't start selection
|
||||
if (!selectionRectangle.visible()) {
|
||||
return;
|
||||
}
|
||||
@ -375,34 +366,25 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
return;
|
||||
}
|
||||
|
||||
// If empty area clicked, remove all selections
|
||||
if (e.target === stage.current) {
|
||||
setSelectedFields([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Do nothing if field not clicked, or if field is not editable
|
||||
if (!e.target.hasName('field-group') || e.target.draggable() === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
// do we pressed shift or ctrl?
|
||||
const metaPressed = e.evt.shiftKey || e.evt.ctrlKey || e.evt.metaKey;
|
||||
const isSelected = transformer.nodes().indexOf(e.target) >= 0;
|
||||
|
||||
if (!metaPressed && !isSelected) {
|
||||
// if no key pressed and the node is not selected
|
||||
// select just one
|
||||
setSelectedFields([e.target]);
|
||||
} else if (metaPressed && isSelected) {
|
||||
// if we pressed keys and node was selected
|
||||
// we need to remove it from selection:
|
||||
const nodes = transformer.nodes().slice(); // use slice to have new copy of array
|
||||
// remove node from array
|
||||
const nodes = transformer.nodes().slice();
|
||||
nodes.splice(nodes.indexOf(e.target), 1);
|
||||
setSelectedFields(nodes);
|
||||
} else if (metaPressed && !isSelected) {
|
||||
// add the node into selection
|
||||
const nodes = transformer.nodes().concat([e.target]);
|
||||
setSelectedFields(nodes);
|
||||
}
|
||||
@ -429,12 +411,10 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
}
|
||||
});
|
||||
|
||||
// If it exists, rerender.
|
||||
localPageFields.forEach((field) => {
|
||||
renderFieldOnLayer(field);
|
||||
});
|
||||
|
||||
// Rerender the transformer
|
||||
interactiveTransformer.current?.forceUpdate();
|
||||
|
||||
pageLayer.current.batchDraw();
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { lazy, useEffect, useMemo } from 'react';
|
||||
import { lazy, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { msg, plural } from '@lingui/core/macro';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { FieldType, RecipientRole } from '@prisma/client';
|
||||
import { FileTextIcon } from 'lucide-react';
|
||||
@ -11,6 +11,7 @@ import { match } from 'ts-pattern';
|
||||
|
||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import type { TDetectedFormField } from '@documenso/lib/types/document-analysis';
|
||||
import type {
|
||||
TCheckboxFieldMeta,
|
||||
TDateFieldMeta,
|
||||
@ -24,12 +25,14 @@ import type {
|
||||
TSignatureFieldMeta,
|
||||
TTextFieldMeta,
|
||||
} from '@documenso/lib/types/field-meta';
|
||||
import { FIELD_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta';
|
||||
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
|
||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Separator } from '@documenso/ui/primitives/separator';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { EditorFieldCheckboxForm } from '~/components/forms/editor/editor-field-checkbox-form';
|
||||
import { EditorFieldDateForm } from '~/components/forms/editor/editor-field-date-form';
|
||||
@ -50,6 +53,51 @@ const EnvelopeEditorFieldsPageRenderer = lazy(
|
||||
async () => import('./envelope-editor-fields-page-renderer'),
|
||||
);
|
||||
|
||||
const detectFormFieldsInDocument = async (params: {
|
||||
envelopeId: string;
|
||||
onProgress: (current: number, total: number) => void;
|
||||
}): Promise<{
|
||||
fieldsPerPage: Map<number, TDetectedFormField[]>;
|
||||
errors: Map<number, Error>;
|
||||
}> => {
|
||||
const { envelopeId, onProgress } = params;
|
||||
const fieldsPerPage = new Map<number, TDetectedFormField[]>();
|
||||
const errors = new Map<number, Error>();
|
||||
|
||||
try {
|
||||
onProgress(0, 1);
|
||||
|
||||
const response = await fetch('/api/ai/detect-fields', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ envelopeId }),
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Field detection failed: ${response.statusText} - ${errorText}`);
|
||||
}
|
||||
|
||||
const detectedFields: TDetectedFormField[] = await response.json();
|
||||
|
||||
for (const field of detectedFields) {
|
||||
if (!fieldsPerPage.has(field.pageNumber)) {
|
||||
fieldsPerPage.set(field.pageNumber, []);
|
||||
}
|
||||
fieldsPerPage.get(field.pageNumber)!.push(field);
|
||||
}
|
||||
|
||||
onProgress(1, 1);
|
||||
} catch (error) {
|
||||
errors.set(0, error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
|
||||
return { fieldsPerPage, errors };
|
||||
};
|
||||
|
||||
const FieldSettingsTypeTranslations: Record<FieldType, MessageDescriptor> = {
|
||||
[FieldType.SIGNATURE]: msg`Signature Settings`,
|
||||
[FieldType.FREE_SIGNATURE]: msg`Free Signature Settings`,
|
||||
@ -72,6 +120,14 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [isDetectingFields, setIsAutoAddingFields] = useState(false);
|
||||
const [processingProgress, setProcessingProgress] = useState<{
|
||||
current: number;
|
||||
total: number;
|
||||
} | null>(null);
|
||||
const [hasAutoPlacedFields, setHasAutoPlacedFields] = useState(false);
|
||||
|
||||
const selectedField = useMemo(
|
||||
() => structuredClone(editorFields.selectedField),
|
||||
@ -85,20 +141,13 @@ 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');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Set the selected recipient to the first recipient in the envelope.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const firstSelectableRecipient = envelope.recipients.find(
|
||||
(recipient) =>
|
||||
@ -108,10 +157,128 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
editorFields.setSelectedRecipient(firstSelectableRecipient?.id ?? null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasAutoPlacedFields || !currentEnvelopeItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
const storageKey = `autoPlaceFields_${envelope.id}`;
|
||||
const storedData = sessionStorage.getItem(storageKey);
|
||||
|
||||
if (!storedData) {
|
||||
return;
|
||||
}
|
||||
|
||||
sessionStorage.removeItem(storageKey);
|
||||
setHasAutoPlacedFields(true);
|
||||
|
||||
try {
|
||||
const { fields: detectedFields, recipientCount } = JSON.parse(storedData) as {
|
||||
fields: TDetectedFormField[];
|
||||
recipientCount: number;
|
||||
};
|
||||
|
||||
let totalAdded = 0;
|
||||
|
||||
const fieldsPerPage = new Map<number, TDetectedFormField[]>();
|
||||
for (const field of detectedFields) {
|
||||
if (!fieldsPerPage.has(field.pageNumber)) {
|
||||
fieldsPerPage.set(field.pageNumber, []);
|
||||
}
|
||||
fieldsPerPage.get(field.pageNumber)!.push(field);
|
||||
}
|
||||
|
||||
for (const [pageNumber, fields] of fieldsPerPage.entries()) {
|
||||
for (const detected of fields) {
|
||||
const [ymin, xmin, ymax, xmax] = detected.boundingBox;
|
||||
const positionX = (xmin / 1000) * 100;
|
||||
const positionY = (ymin / 1000) * 100;
|
||||
const width = ((xmax - xmin) / 1000) * 100;
|
||||
const height = ((ymax - ymin) / 1000) * 100;
|
||||
|
||||
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({
|
||||
envelopeItemId: currentEnvelopeItem.id,
|
||||
page: pageNumber,
|
||||
type: fieldType,
|
||||
positionX,
|
||||
positionY,
|
||||
width,
|
||||
height,
|
||||
recipientId: resolvedRecipientId,
|
||||
fieldMeta: structuredClone(FIELD_META_DEFAULT_VALUES[fieldType]),
|
||||
});
|
||||
totalAdded++;
|
||||
} catch (error) {
|
||||
console.error(`Failed to add field on page ${pageNumber}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (totalAdded > 0) {
|
||||
toast({
|
||||
title: t`Recipients and fields added`,
|
||||
description: t`Added ${recipientCount} ${plural(recipientCount, {
|
||||
one: 'recipient',
|
||||
other: 'recipients',
|
||||
})} and ${totalAdded} ${plural(totalAdded, { one: 'field', other: 'fields' })}`,
|
||||
duration: 5000,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: t`Recipients added`,
|
||||
description: t`Added ${recipientCount} ${plural(recipientCount, {
|
||||
one: 'recipient',
|
||||
other: 'recipients',
|
||||
})}. No fields were detected in the document.`,
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to auto-place fields:', error);
|
||||
toast({
|
||||
title: t`Field placement failed`,
|
||||
description: t`Failed to automatically place fields. You can add them manually.`,
|
||||
variant: 'destructive',
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
}, [
|
||||
currentEnvelopeItem,
|
||||
envelope.id,
|
||||
envelope.recipients,
|
||||
editorFields,
|
||||
hasAutoPlacedFields,
|
||||
t,
|
||||
toast,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full">
|
||||
<div className="flex w-full flex-col overflow-y-auto">
|
||||
<div className="relative flex w-full flex-col overflow-y-auto">
|
||||
{/* Horizontal envelope item selector */}
|
||||
{isDetectingFields && (
|
||||
<>
|
||||
<div className="edge-glow edge-glow-top pointer-events-none fixed left-0 right-0 top-0 z-20 h-32" />
|
||||
<div className="edge-glow edge-glow-right pointer-events-none fixed bottom-0 right-0 top-0 z-20 w-32" />
|
||||
<div className="edge-glow edge-glow-bottom pointer-events-none fixed bottom-0 left-0 right-0 z-20 h-32" />
|
||||
<div className="edge-glow edge-glow-left pointer-events-none fixed bottom-0 left-0 top-0 z-20 w-32" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<EnvelopeRendererFileSelector fields={editorFields.localFields} />
|
||||
|
||||
{/* Document View */}
|
||||
@ -202,6 +369,129 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
selectedRecipientId={editorFields.selectedRecipient?.id ?? null}
|
||||
selectedEnvelopeItemId={currentEnvelopeItem?.id ?? null}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className="mt-4 w-full"
|
||||
variant="outline"
|
||||
disabled={isDetectingFields}
|
||||
onClick={async () => {
|
||||
setIsAutoAddingFields(true);
|
||||
setProcessingProgress(null);
|
||||
|
||||
try {
|
||||
if (!currentEnvelopeItem) {
|
||||
toast({
|
||||
title: t`No document selected`,
|
||||
description: t`No document selected. Please reload the page and try again.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentEnvelopeItem.documentDataId) {
|
||||
toast({
|
||||
title: t`Document data missing`,
|
||||
description: t`Document data not found. Please try reloading the page.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { fieldsPerPage, errors } = await detectFormFieldsInDocument({
|
||||
envelopeId: envelope.id,
|
||||
onProgress: (current, total) => {
|
||||
setProcessingProgress({ current, total });
|
||||
},
|
||||
});
|
||||
|
||||
let totalAdded = 0;
|
||||
for (const [pageNumber, detectedFields] of fieldsPerPage.entries()) {
|
||||
for (const detected of detectedFields) {
|
||||
const [ymin, xmin, ymax, xmax] = detected.boundingBox;
|
||||
const positionX = (xmin / 1000) * 100;
|
||||
const positionY = (ymin / 1000) * 100;
|
||||
const width = ((xmax - xmin) / 1000) * 100;
|
||||
const height = ((ymax - ymin) / 1000) * 100;
|
||||
|
||||
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({
|
||||
envelopeItemId: currentEnvelopeItem.id,
|
||||
page: pageNumber,
|
||||
type: fieldType,
|
||||
positionX,
|
||||
positionY,
|
||||
width,
|
||||
height,
|
||||
recipientId: resolvedRecipientId,
|
||||
fieldMeta: structuredClone(FIELD_META_DEFAULT_VALUES[fieldType]),
|
||||
});
|
||||
totalAdded++;
|
||||
} catch (error) {
|
||||
console.error(`Failed to add field on page ${pageNumber}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const successfulPages = fieldsPerPage.size;
|
||||
const failedPages = errors.size;
|
||||
|
||||
if (totalAdded > 0) {
|
||||
let description = t`Added ${totalAdded} fields`;
|
||||
if (fieldsPerPage.size > 1) {
|
||||
description = t`Added ${totalAdded} fields across ${successfulPages} pages`;
|
||||
}
|
||||
if (failedPages > 0) {
|
||||
description = t`Added ${totalAdded} fields across ${successfulPages} pages. ${failedPages} pages failed.`;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: t`Fields added`,
|
||||
description,
|
||||
});
|
||||
} else if (failedPages > 0) {
|
||||
toast({
|
||||
title: t`Field detection failed`,
|
||||
description: t`Failed to detect fields on ${failedPages} pages. Please try again.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: t`No fields detected`,
|
||||
description: t`No fields were detected in the document`,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t`Processing error`,
|
||||
description: t`An unexpected error occurred while processing pages.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsAutoAddingFields(false);
|
||||
setProcessingProgress(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isDetectingFields ? <Trans>Processing...</Trans> : <Trans>Auto add fields</Trans>}
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
{/* Field details section. */}
|
||||
|
||||
@ -1,12 +1,11 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd';
|
||||
import type { DropResult } from '@hello-pangea/dnd';
|
||||
import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { DocumentStatus } from '@prisma/client';
|
||||
import { FileWarningIcon, GripVerticalIcon, Loader2 } from 'lucide-react';
|
||||
import { X } from 'lucide-react';
|
||||
import { FileWarningIcon, GripVerticalIcon, Loader2, X } from 'lucide-react';
|
||||
import { ErrorCode as DropzoneErrorCode, type FileRejection } from 'react-dropzone';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
@ -174,7 +173,6 @@ export const EnvelopeEditorUploadPage = () => {
|
||||
fields: envelope.fields.filter((field) => field.envelopeItemId !== envelopeItemId),
|
||||
});
|
||||
|
||||
// Reset editor fields.
|
||||
editorFields.resetForm(fieldsWithoutDeletedItem);
|
||||
};
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { type ReactNode, useState } from 'react';
|
||||
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { plural } from '@lingui/core/macro';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
import { Loader } from 'lucide-react';
|
||||
import {
|
||||
@ -27,7 +27,13 @@ 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 { RecipientDetectionPromptDialog } from '~/components/dialogs/recipient-detection-prompt-dialog';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
import {
|
||||
type RecipientForCreation,
|
||||
detectRecipientsInDocument,
|
||||
ensureRecipientEmails,
|
||||
} from '~/utils/detect-document-recipients';
|
||||
|
||||
export interface EnvelopeDropZoneWrapperProps {
|
||||
children: ReactNode;
|
||||
@ -52,6 +58,10 @@ export const EnvelopeDropZoneWrapper = ({
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showExtractionPrompt, setShowExtractionPrompt] = useState(false);
|
||||
const [uploadedDocumentId, setUploadedDocumentId] = useState<string | null>(null);
|
||||
const [pendingRecipients, setPendingRecipients] = useState<RecipientForCreation[] | null>(null);
|
||||
const [shouldNavigateAfterPromptClose, setShouldNavigateAfterPromptClose] = useState(true);
|
||||
|
||||
const userTimezone =
|
||||
TIME_ZONES.find((timezone) => timezone === Intl.DateTimeFormat().resolvedOptions().timeZone) ??
|
||||
@ -60,6 +70,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 +119,15 @@ export const EnvelopeDropZoneWrapper = ({
|
||||
documentId: id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
setUploadedDocumentId(id);
|
||||
setPendingRecipients(null);
|
||||
setShouldNavigateAfterPromptClose(true);
|
||||
setShowExtractionPrompt(true);
|
||||
} else {
|
||||
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);
|
||||
|
||||
@ -132,7 +144,7 @@ export const EnvelopeDropZoneWrapper = ({
|
||||
.otherwise(() => t`An error occurred during upload.`);
|
||||
|
||||
toast({
|
||||
title: t`Error`,
|
||||
title: t`Upload failed`,
|
||||
description: errorMessage,
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
@ -161,7 +173,6 @@ export const EnvelopeDropZoneWrapper = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Since users can only upload only one file (no multi-upload), we only handle the first file rejection
|
||||
const { file, errors } = fileRejections[0];
|
||||
|
||||
if (!errors.length) {
|
||||
@ -201,6 +212,116 @@ export const EnvelopeDropZoneWrapper = ({
|
||||
variant: 'destructive',
|
||||
});
|
||||
};
|
||||
|
||||
const navigateToEnvelopeEditor = () => {
|
||||
if (!uploadedDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pathPrefix = formatDocumentsPath(team.url);
|
||||
void navigate(`${pathPrefix}/${uploadedDocumentId}/edit`);
|
||||
};
|
||||
|
||||
const handleStartRecipientDetection = async () => {
|
||||
if (!uploadedDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const recipients = await detectRecipientsInDocument(uploadedDocumentId);
|
||||
|
||||
if (recipients.length === 0) {
|
||||
toast({
|
||||
title: t`No recipients detected`,
|
||||
description: t`You can add recipients manually in the editor`,
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
setShouldNavigateAfterPromptClose(true);
|
||||
setShowExtractionPrompt(false);
|
||||
navigateToEnvelopeEditor();
|
||||
return;
|
||||
}
|
||||
|
||||
const recipientsWithEmails = ensureRecipientEmails(recipients, uploadedDocumentId);
|
||||
|
||||
setPendingRecipients(recipientsWithEmails);
|
||||
setShouldNavigateAfterPromptClose(false);
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error && error.message === 'NO_RECIPIENTS_DETECTED')) {
|
||||
const parsedError = AppError.parseError(error);
|
||||
|
||||
toast({
|
||||
title: t`Failed to detect recipients`,
|
||||
description: parsedError.userMessage || t`You can add recipients manually in the editor`,
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkipRecipientDetection = () => {
|
||||
setShouldNavigateAfterPromptClose(true);
|
||||
setShowExtractionPrompt(false);
|
||||
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} ${plural(
|
||||
recipientsToCreate.length,
|
||||
{
|
||||
one: 'recipient',
|
||||
other: 'recipients',
|
||||
},
|
||||
)}`,
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
setShowExtractionPrompt(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) => {
|
||||
setShowExtractionPrompt(open);
|
||||
|
||||
if (open) {
|
||||
setShouldNavigateAfterPromptClose(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!open && shouldNavigateAfterPromptClose) {
|
||||
navigateToEnvelopeEditor();
|
||||
}
|
||||
};
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
accept: {
|
||||
'application/pdf': ['.pdf'],
|
||||
@ -267,6 +388,15 @@ export const EnvelopeDropZoneWrapper = ({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<RecipientDetectionPromptDialog
|
||||
open={showExtractionPrompt}
|
||||
onOpenChange={handlePromptDialogOpenChange}
|
||||
onAccept={handleStartRecipientDetection}
|
||||
onSkip={handleSkipRecipientDetection}
|
||||
recipients={pendingRecipients}
|
||||
onRecipientsSubmit={handleRecipientsConfirm}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { msg, plural } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
@ -27,7 +27,14 @@ import {
|
||||
} from '@documenso/ui/primitives/tooltip';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { RecipientDetectionPromptDialog } from '~/components/dialogs/recipient-detection-prompt-dialog';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
import { detectFieldsInDocument } from '~/utils/detect-document-fields';
|
||||
import {
|
||||
type RecipientForCreation,
|
||||
detectRecipientsInDocument,
|
||||
ensureRecipientEmails,
|
||||
} from '~/utils/detect-document-recipients';
|
||||
|
||||
export type EnvelopeUploadButtonProps = {
|
||||
className?: string;
|
||||
@ -35,9 +42,6 @@ export type EnvelopeUploadButtonProps = {
|
||||
folderId?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Upload an envelope
|
||||
*/
|
||||
export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUploadButtonProps) => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
@ -55,8 +59,14 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
|
||||
const { quota, remaining, refreshLimits, maximumEnvelopeItemCount } = useLimits();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showExtractionPrompt, setShowExtractionPrompt] = useState(false);
|
||||
const [uploadedDocumentId, setUploadedDocumentId] = useState<string | null>(null);
|
||||
const [pendingRecipients, setPendingRecipients] = useState<RecipientForCreation[] | null>(null);
|
||||
const [shouldNavigateAfterPromptClose, setShouldNavigateAfterPromptClose] = useState(true);
|
||||
const [isAutoAddingFields, setIsAutoAddingFields] = useState(false);
|
||||
|
||||
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 +118,26 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
|
||||
? formatDocumentsPath(team.url)
|
||||
: formatTemplatesPath(team.url);
|
||||
|
||||
await navigate(`${pathPrefix}/${id}/edit`);
|
||||
if (type === EnvelopeType.DOCUMENT) {
|
||||
setUploadedDocumentId(id);
|
||||
setPendingRecipients(null);
|
||||
setShouldNavigateAfterPromptClose(true);
|
||||
setShowExtractionPrompt(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 {
|
||||
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);
|
||||
|
||||
@ -136,7 +156,7 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
|
||||
.otherwise(() => t`An error occurred while uploading your document.`);
|
||||
|
||||
toast({
|
||||
title: t`Error`,
|
||||
title: t`Upload failed`,
|
||||
description: errorMessage,
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
@ -169,6 +189,181 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
|
||||
});
|
||||
};
|
||||
|
||||
const navigateToEnvelopeEditor = () => {
|
||||
if (!uploadedDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pathPrefix = formatDocumentsPath(team.url);
|
||||
void navigate(`${pathPrefix}/${uploadedDocumentId}/edit`);
|
||||
};
|
||||
|
||||
const handleStartRecipientDetection = async () => {
|
||||
if (!uploadedDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const recipients = await detectRecipientsInDocument(uploadedDocumentId);
|
||||
|
||||
if (recipients.length === 0) {
|
||||
toast({
|
||||
title: t`No recipients detected`,
|
||||
description: t`You can add recipients manually in the editor`,
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
setShouldNavigateAfterPromptClose(true);
|
||||
setShowExtractionPrompt(false);
|
||||
navigateToEnvelopeEditor();
|
||||
return;
|
||||
}
|
||||
|
||||
const recipientsWithEmails = ensureRecipientEmails(recipients, uploadedDocumentId);
|
||||
|
||||
setPendingRecipients(recipientsWithEmails);
|
||||
setShouldNavigateAfterPromptClose(false);
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
// Only show toast if this wasn't a "no recipients found" case
|
||||
if (error.code !== 'NO_RECIPIENTS_DETECTED') {
|
||||
toast({
|
||||
title: t`Failed to analyze recipients`,
|
||||
description: error.userMessage || t`You can add recipients manually in the editor`,
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkipRecipientDetection = () => {
|
||||
setShouldNavigateAfterPromptClose(true);
|
||||
setShowExtractionPrompt(false);
|
||||
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} ${plural(
|
||||
recipientsToCreate.length,
|
||||
{
|
||||
one: 'recipient',
|
||||
other: 'recipients',
|
||||
},
|
||||
)}`,
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
setShowExtractionPrompt(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,
|
||||
});
|
||||
|
||||
// Error is handled, dialog stays open for retry
|
||||
}
|
||||
};
|
||||
|
||||
const handleAutoAddFields = async (recipientsToCreate: RecipientForCreation[]) => {
|
||||
if (!uploadedDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsAutoAddingFields(true);
|
||||
|
||||
try {
|
||||
await createRecipients({
|
||||
envelopeId: uploadedDocumentId,
|
||||
data: recipientsToCreate,
|
||||
});
|
||||
|
||||
let detectedFields;
|
||||
try {
|
||||
detectedFields = await detectFieldsInDocument(uploadedDocumentId);
|
||||
} catch (error) {
|
||||
console.error('Field detection failed:', error);
|
||||
|
||||
toast({
|
||||
title: t`Field detection failed`,
|
||||
description: t`Recipients added successfully, but field detection encountered an error. You can add fields manually.`,
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
|
||||
setShowExtractionPrompt(false);
|
||||
setPendingRecipients(null);
|
||||
setIsAutoAddingFields(false);
|
||||
|
||||
const pathPrefix = formatDocumentsPath(team.url);
|
||||
void navigate(`${pathPrefix}/${uploadedDocumentId}/edit?step=addFields`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (detectedFields.length > 0) {
|
||||
sessionStorage.setItem(
|
||||
`autoPlaceFields_${uploadedDocumentId}`,
|
||||
JSON.stringify({
|
||||
fields: detectedFields,
|
||||
recipientCount: recipientsToCreate.length,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
setShowExtractionPrompt(false);
|
||||
setPendingRecipients(null);
|
||||
setIsAutoAddingFields(false);
|
||||
|
||||
const pathPrefix = formatDocumentsPath(team.url);
|
||||
void navigate(`${pathPrefix}/${uploadedDocumentId}/edit?step=addFields`);
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
toast({
|
||||
title: t`Failed to add recipients`,
|
||||
description: error.userMessage || t`Please try again`,
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
|
||||
setIsAutoAddingFields(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePromptDialogOpenChange = (open: boolean) => {
|
||||
setShowExtractionPrompt(open);
|
||||
|
||||
if (open) {
|
||||
setShouldNavigateAfterPromptClose(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!open && shouldNavigateAfterPromptClose) {
|
||||
navigateToEnvelopeEditor();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('relative', className)}>
|
||||
<TooltipProvider>
|
||||
@ -201,6 +396,17 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<RecipientDetectionPromptDialog
|
||||
open={showExtractionPrompt}
|
||||
onOpenChange={handlePromptDialogOpenChange}
|
||||
onAccept={handleStartRecipientDetection}
|
||||
onSkip={handleSkipRecipientDetection}
|
||||
recipients={pendingRecipients}
|
||||
onRecipientsSubmit={handleRecipientsConfirm}
|
||||
onAutoAddFields={handleAutoAddFields}
|
||||
isProcessingRecipients={isAutoAddingFields}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
39
apps/remix/app/utils/detect-document-fields.ts
Normal file
39
apps/remix/app/utils/detect-document-fields.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { TDetectedFormField } from '@documenso/lib/types/document-analysis';
|
||||
|
||||
export const detectFieldsInDocument = async (envelopeId: string): Promise<TDetectedFormField[]> => {
|
||||
const response = await fetch('/api/ai/detect-fields', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ envelopeId }),
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
|
||||
console.error('Field detection failed:', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
error: errorText,
|
||||
});
|
||||
|
||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
||||
message: `Field detection failed: ${response.statusText}`,
|
||||
userMessage: 'Failed to detect fields in the document. Please try adding fields manually.',
|
||||
});
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!Array.isArray(data)) {
|
||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
||||
message: 'Invalid response from field detection API - expected array',
|
||||
userMessage: 'Failed to detect fields in the document. Please try again.',
|
||||
});
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
67
apps/remix/app/utils/detect-document-recipients.ts
Normal file
67
apps/remix/app/utils/detect-document-recipients.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import { RecipientRole } from '@prisma/client';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
|
||||
export type SuggestedRecipient = {
|
||||
name: string;
|
||||
email?: string;
|
||||
role: 'SIGNER' | 'APPROVER' | 'CC';
|
||||
signingOrder?: number;
|
||||
};
|
||||
|
||||
export const detectRecipientsInDocument = async (
|
||||
envelopeId: string,
|
||||
): Promise<SuggestedRecipient[]> => {
|
||||
try {
|
||||
const response = await fetch('/api/ai/detect-recipients', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ envelopeId }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
||||
message: 'Failed to detect recipients',
|
||||
});
|
||||
}
|
||||
|
||||
return (await response.json()) as SuggestedRecipient[];
|
||||
} catch (error) {
|
||||
throw AppError.parseError(error);
|
||||
}
|
||||
};
|
||||
|
||||
export type RecipientForCreation = {
|
||||
name: string;
|
||||
email: string;
|
||||
role: RecipientRole;
|
||||
signingOrder?: number;
|
||||
};
|
||||
|
||||
export const ensureRecipientEmails = (
|
||||
recipients: SuggestedRecipient[],
|
||||
envelopeId: string,
|
||||
): RecipientForCreation[] => {
|
||||
const allowedRoles: RecipientRole[] = [
|
||||
RecipientRole.SIGNER,
|
||||
RecipientRole.APPROVER,
|
||||
RecipientRole.CC,
|
||||
];
|
||||
|
||||
return recipients.map((recipient) => {
|
||||
const email = recipient.email ?? '';
|
||||
|
||||
const candidateRole = recipient.role as RecipientRole;
|
||||
const normalizedRole = allowedRoles.includes(candidateRole)
|
||||
? candidateRole
|
||||
: RecipientRole.SIGNER;
|
||||
|
||||
return {
|
||||
...recipient,
|
||||
email,
|
||||
role: normalizedRole,
|
||||
};
|
||||
});
|
||||
};
|
||||
@ -14,6 +14,8 @@
|
||||
"with:env": "dotenv -e ../../.env -e ../../.env.local --"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/google": "^2.0.25",
|
||||
"@ai-sdk/react": "^2.0.82",
|
||||
"@cantoo/pdf-lib": "^2.5.2",
|
||||
"@documenso/api": "*",
|
||||
"@documenso/assets": "*",
|
||||
@ -39,6 +41,7 @@
|
||||
"@react-router/serve": "^7.6.0",
|
||||
"@simplewebauthn/browser": "^9.0.1",
|
||||
"@simplewebauthn/server": "^9.0.3",
|
||||
"ai": "^5.0.82",
|
||||
"autoprefixer": "^10.4.13",
|
||||
"colord": "^2.9.3",
|
||||
"content-disposition": "^0.5.4",
|
||||
@ -70,6 +73,7 @@
|
||||
"remix-themes": "^2.0.4",
|
||||
"satori": "^0.12.1",
|
||||
"sharp": "0.32.6",
|
||||
"skia-canvas": "^3.0.8",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"ts-pattern": "^5.0.5",
|
||||
"ua-parser-js": "^1.0.37",
|
||||
|
||||
495
apps/remix/server/api/document-analysis/index.ts
Normal file
495
apps/remix/server/api/document-analysis/index.ts
Normal file
@ -0,0 +1,495 @@
|
||||
import { generateObject } from 'ai';
|
||||
import { Hono } from 'hono';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { Canvas, Image } from 'skia-canvas';
|
||||
|
||||
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { resizeAndCompressImage } from '@documenso/lib/server-only/image/resize-and-compress-image';
|
||||
import { renderPdfToImage } from '@documenso/lib/server-only/pdf/render-pdf-to-image';
|
||||
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
|
||||
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
import { resolveRecipientEmail } from '@documenso/lib/utils/recipients';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import type { HonoEnv } from '../../router';
|
||||
import { ANALYZE_RECIPIENTS_PROMPT, DETECT_OBJECTS_PROMPT } from './prompts';
|
||||
import {
|
||||
type TAnalyzeRecipientsResponse,
|
||||
type TDetectFormFieldsResponse,
|
||||
type TDetectedRecipient,
|
||||
ZAnalyzeRecipientsRequestSchema,
|
||||
ZDetectFormFieldsRequestSchema,
|
||||
ZDetectedFormFieldSchema,
|
||||
ZDetectedRecipientLLMSchema,
|
||||
} from './types';
|
||||
|
||||
type FieldDetectionRecipient = {
|
||||
id: number;
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
role: string;
|
||||
signingOrder: number | null;
|
||||
};
|
||||
|
||||
const buildFieldDetectionPrompt = (recipients: FieldDetectionRecipient[]) => {
|
||||
if (recipients.length === 0) {
|
||||
return DETECT_OBJECTS_PROMPT;
|
||||
}
|
||||
|
||||
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 `${DETECT_OBJECTS_PROMPT}\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<TDetectFormFieldsResponse> => {
|
||||
const compressedImageBuffer = await resizeAndCompressImage(imageBuffer);
|
||||
const base64Image = compressedImageBuffer.toString('base64');
|
||||
const prompt = buildFieldDetectionPrompt(recipients);
|
||||
|
||||
const result = await generateObject({
|
||||
model: 'google/gemini-3-pro-preview',
|
||||
output: 'array',
|
||||
schema: ZDetectedFormFieldSchema,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'image',
|
||||
image: `data:image/jpeg;base64,${base64Image}`,
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: prompt,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const recipientIds = new Set(recipients.map((recipient) => recipient.id));
|
||||
const fallbackRecipientId = recipients[0]?.id;
|
||||
|
||||
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,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// Limit recipient detection to first 3 pages for performance and cost efficiency
|
||||
const MAX_PAGES_FOR_RECIPIENT_ANALYSIS = 3;
|
||||
|
||||
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.',
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
export const aiRoute = new Hono<HonoEnv>()
|
||||
.post('/detect-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;
|
||||
|
||||
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<string, number> = {
|
||||
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<TDetectFormFieldsResponse>(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('/detect-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;
|
||||
|
||||
const documentData = await authorizeDocumentAccess(envelopeId, user.id);
|
||||
|
||||
const pdfBytes = await getFileServerSide({
|
||||
type: documentData.type,
|
||||
data: documentData.initialData || documentData.data,
|
||||
});
|
||||
|
||||
const renderedPages = await renderPdfToImage(pdfBytes);
|
||||
|
||||
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: ANALYZE_RECIPIENTS_PROMPT,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
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 { recipients } = result.value;
|
||||
|
||||
const recipientsWithEmails = recipients.map((recipient) => {
|
||||
const email = resolveRecipientEmail(recipient.email);
|
||||
|
||||
const normalizedRecipient: TDetectedRecipient = {
|
||||
...recipient,
|
||||
email,
|
||||
};
|
||||
|
||||
recipientIndex += 1;
|
||||
|
||||
return normalizedRecipient;
|
||||
});
|
||||
|
||||
allRecipients.push(...recipientsWithEmails);
|
||||
}
|
||||
|
||||
return c.json<TAnalyzeRecipientsResponse>(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.',
|
||||
});
|
||||
}
|
||||
});
|
||||
121
apps/remix/server/api/document-analysis/prompts.ts
Normal file
121
apps/remix/server/api/document-analysis/prompts.ts
Normal file
@ -0,0 +1,121 @@
|
||||
export const DETECT_OBJECTS_PROMPT = `You are analyzing a form document image to detect fillable fields for the Documenso document signing platform.
|
||||
|
||||
IMPORTANT RULES:
|
||||
1. Only detect EMPTY/UNFILLED fields (ignore boxes that already contain text or data)
|
||||
2. Analyze nearby text labels to determine the field type
|
||||
3. Return bounding boxes for the fillable area only, NOT the label text
|
||||
4. Each boundingBox must be in the format [ymin, xmin, ymax, xmax] where all coordinates are NORMALIZED to a 0-1000 scale
|
||||
|
||||
CRITICAL: UNDERSTANDING FILLABLE AREAS
|
||||
The "fillable area" is ONLY the empty space where a user will write, type, sign, or check.
|
||||
- ✓ CORRECT: The blank underscore where someone writes their name: "Name: _________" → box ONLY the underscores
|
||||
- ✓ CORRECT: The empty white rectangle inside a box outline → box ONLY the empty space, not any printed text
|
||||
- ✓ CORRECT: The blank space to the right of a label: "Email: [ empty box ]" → box ONLY the empty box, exclude "Email:"
|
||||
- ✗ INCORRECT: Including the word "Signature:" that appears to the left of a signature line
|
||||
- ✗ INCORRECT: Including printed labels, instructions, or descriptive text near the field
|
||||
- ✗ INCORRECT: Extending the box to include text just because it's close to the fillable area
|
||||
|
||||
VISUALIZING THE DISTINCTION:
|
||||
- If there's text (printed words/labels) near an empty box or line, they are SEPARATE elements
|
||||
- The text is a LABEL telling the user what to fill
|
||||
- The empty space is the FILLABLE AREA where they actually write/sign
|
||||
- Your bounding box should capture ONLY the empty space, even if the label is immediately adjacent
|
||||
|
||||
FIELD TYPES TO DETECT:
|
||||
• SIGNATURE - Signature lines, boxes labeled 'Signature', 'Sign here', 'Authorized signature', 'X____'
|
||||
• INITIALS - Small boxes labeled 'Initials', 'Initial here', typically smaller than signature fields
|
||||
• NAME - Boxes labeled 'Name', 'Full name', 'Your name', 'Print name', 'Printed name'
|
||||
• EMAIL - Boxes labeled 'Email', 'Email address', 'E-mail', 'Email:'
|
||||
• DATE - Boxes labeled 'Date', 'Date signed', "Today's date", or showing date format placeholders like 'MM/DD/YYYY', '__/__/____'
|
||||
• CHECKBOX - Empty checkbox squares (☐) with or without labels, typically small square boxes
|
||||
• RADIO - Empty radio button circles (○) in groups, typically circular selection options
|
||||
• NUMBER - Boxes labeled with numeric context: 'Amount', 'Quantity', 'Phone', 'Phone number', 'ZIP', 'ZIP code', 'Age', 'Price', '#'
|
||||
• DROPDOWN - Boxes with dropdown indicators (▼, ↓) or labeled 'Select', 'Choose', 'Please select'
|
||||
• TEXT - Any other empty text input boxes, general input fields, unlabeled boxes, or when field type is uncertain
|
||||
|
||||
DETECTION GUIDELINES:
|
||||
- Read text located near the box (above, to the left, or inside the box boundary) to infer the field type
|
||||
- IMPORTANT: Use the nearby text to CLASSIFY the field type, but DO NOT include that text in the bounding box
|
||||
- If you're uncertain which type fits best, default to TEXT
|
||||
- For checkboxes and radio buttons: Detect each individual box/circle separately, not the label
|
||||
- Signature fields are often longer horizontal lines or larger boxes
|
||||
- Date fields often show format hints or date separators (slashes, dashes)
|
||||
- Look for visual patterns: underscores (____), horizontal lines, box outlines
|
||||
|
||||
BOUNDING BOX PLACEMENT (CRITICAL):
|
||||
- Your coordinates must capture ONLY the empty fillable space (the blank area where input goes)
|
||||
- Once you find the fillable region, LOCK the box to the full boundary of that region (top, bottom, left, right). Do not leave the box floating over just the starting edge.
|
||||
- If the field is defined by a line or a rectangular border, extend xmin/xmax/ymin/ymax across the entire line/border so the box spans the whole writable area end-to-end.
|
||||
- EXCLUDE all printed text labels, even if they are:
|
||||
· Directly to the left of the field (e.g., "Name: _____")
|
||||
· Directly above the field (e.g., "Signature" printed above a line)
|
||||
· Very close to the field with minimal spacing
|
||||
· Inside the same outlined box as the fillable area
|
||||
- The label text helps you IDENTIFY the field type, but must be EXCLUDED from the bounding box
|
||||
- If you detect a label "Email:" followed by a blank box, draw the box around ONLY the blank box, not the word "Email:"
|
||||
- The box should never cover only the leftmost few characters of a long field. For "Signature: ____________", the box must stretch from the first underscore to the last.
|
||||
|
||||
COORDINATE SYSTEM:
|
||||
- [ymin, xmin, ymax, xmax] normalized to 0-1000 scale
|
||||
- Top-left corner: ymin and xmin close to 0
|
||||
- Bottom-right corner: ymax and xmax close to 1000
|
||||
- Coordinates represent positions on a 1000x1000 grid overlaid on the image
|
||||
|
||||
FIELD SIZING STRATEGY FOR LINE-BASED FIELDS:
|
||||
When detecting thin horizontal lines for SIGNATURE, INITIALS, NAME, EMAIL, DATE, TEXT, or NUMBER fields:
|
||||
1. Analyze the visual context around the detected line:
|
||||
- Look at the empty space ABOVE the detected line
|
||||
- Observe the spacing to any text labels, headers, or other form elements above
|
||||
- Assess what would be a reasonable field height to make the field clearly visible when filled
|
||||
2. Expand UPWARD from the detected line to create a usable field:
|
||||
- Keep ymax (bottom) at the detected line position (the line becomes the bottom edge)
|
||||
- Extend ymin (top) upward into the available whitespace
|
||||
- Aim to use 60-80% of the clear whitespace above the line, while being reasonable
|
||||
- The expanded field should provide comfortable space for signing/writing (minimum 30 units tall)
|
||||
3. Apply minimum dimensions: height at least 30 units (3% of 1000-scale), width at least 36 units
|
||||
4. Ensure ymin >= 0 (do not go off-page). If ymin would be negative, clamp to 0
|
||||
5. Do NOT apply this expansion to CHECKBOX, RADIO, or DROPDOWN fields - use detected dimensions for those
|
||||
6. Example: If you detect a signature line at ymax=500 with clear whitespace extending up to y=400:
|
||||
- Available whitespace: 100 units
|
||||
- Use 60-80% of that: 60-80 units
|
||||
- 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`;
|
||||
|
||||
export const ANALYZE_RECIPIENTS_PROMPT = `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, leave the email field empty.
|
||||
7. 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
|
||||
- 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
|
||||
- If a signature line exists but no name is associated with it, DO NOT return a recipient with name "<UNKNOWN>". Skip it.
|
||||
|
||||
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
|
||||
- Using placeholder names like "<UNKNOWN>", "Unknown", "Signer"`;
|
||||
98
apps/remix/server/api/document-analysis/types.ts
Normal file
98
apps/remix/server/api/document-analysis/types.ts
Normal file
@ -0,0 +1,98 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { TDetectedFormField } from '@documenso/lib/types/document-analysis';
|
||||
|
||||
export const ZGenerateTextRequestSchema = z.object({
|
||||
prompt: z.string().min(1, 'Prompt is required').max(5000, 'Prompt is too long'),
|
||||
});
|
||||
|
||||
export const ZGenerateTextResponseSchema = z.object({
|
||||
text: z.string(),
|
||||
});
|
||||
|
||||
export type TGenerateTextRequest = z.infer<typeof ZGenerateTextRequestSchema>;
|
||||
export type TGenerateTextResponse = z.infer<typeof ZGenerateTextResponseSchema>;
|
||||
|
||||
export const ZDetectedFormFieldSchema = z.object({
|
||||
boundingBox: z
|
||||
.array(z.number())
|
||||
.length(4)
|
||||
.describe('Bounding box [ymin, xmin, ymax, xmax] in normalized 0-1000 range'),
|
||||
label: z
|
||||
.enum([
|
||||
'SIGNATURE',
|
||||
'INITIALS',
|
||||
'NAME',
|
||||
'EMAIL',
|
||||
'DATE',
|
||||
'TEXT',
|
||||
'NUMBER',
|
||||
'RADIO',
|
||||
'CHECKBOX',
|
||||
'DROPDOWN',
|
||||
])
|
||||
.describe('Documenso field type inferred from nearby label text or visual characteristics'),
|
||||
pageNumber: z
|
||||
.number()
|
||||
.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({
|
||||
envelopeId: z.string().min(1, { message: 'Envelope ID is required' }),
|
||||
});
|
||||
|
||||
export const ZDetectFormFieldsResponseSchema = z.array(ZDetectedFormFieldSchema);
|
||||
|
||||
export type TDetectFormFieldsRequest = z.infer<typeof ZDetectFormFieldsRequestSchema>;
|
||||
export type TDetectFormFieldsResponse = z.infer<typeof ZDetectFormFieldsResponseSchema>;
|
||||
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 = <TSchema extends z.ZodTypeAny>(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().optional().describe('Email address for the recipient (if found in document).'),
|
||||
);
|
||||
|
||||
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<typeof ZDetectedRecipientSchema>;
|
||||
export type TAnalyzeRecipientsRequest = z.infer<typeof ZAnalyzeRecipientsRequestSchema>;
|
||||
export type TAnalyzeRecipientsResponse = z.infer<typeof ZAnalyzeRecipientsResponseSchema>;
|
||||
@ -14,6 +14,7 @@ import { getIpAddress } from '@documenso/lib/universal/get-ip-address';
|
||||
import { logger } from '@documenso/lib/utils/logger';
|
||||
import { openApiDocument } from '@documenso/trpc/server/open-api';
|
||||
|
||||
import { aiRoute } from './api/document-analysis/index';
|
||||
import { downloadRoute } from './api/download/download';
|
||||
import { filesRoute } from './api/files/files';
|
||||
import { type AppContext, appContext } from './context';
|
||||
@ -84,6 +85,9 @@ app.route('/api/auth', auth);
|
||||
// Files route.
|
||||
app.route('/api/files', filesRoute);
|
||||
|
||||
// AI route.
|
||||
app.route('/api/ai', aiRoute);
|
||||
|
||||
// API servers.
|
||||
app.use(`/api/v1/*`, cors());
|
||||
app.route('/api/v1', tsRestHonoApp);
|
||||
|
||||
8005
package-lock.json
generated
8005
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -83,6 +83,7 @@
|
||||
"@documenso/prisma": "^0.0.0",
|
||||
"@lingui/conf": "^5.2.0",
|
||||
"@lingui/core": "^5.2.0",
|
||||
"ai": "^5.0.82",
|
||||
"inngest-cli": "^0.29.1",
|
||||
"luxon": "^3.5.0",
|
||||
"mupdf": "^1.0.0",
|
||||
|
||||
@ -43,6 +43,7 @@
|
||||
"micro": "^10.0.1",
|
||||
"nanoid": "^5.1.5",
|
||||
"oslo": "^0.17.0",
|
||||
"pdfjs-dist": "3.11.174",
|
||||
"pg": "^8.11.3",
|
||||
"pino": "^9.7.0",
|
||||
"pino-pretty": "^13.0.0",
|
||||
|
||||
@ -116,28 +116,28 @@ async function getTeamInsights(
|
||||
): Promise<OrganisationDetailedInsights> {
|
||||
const teamsQuery = kyselyPrisma.$kysely
|
||||
.selectFrom('Team as t')
|
||||
.leftJoin('Envelope as e', (join) =>
|
||||
join
|
||||
.onRef('t.id', '=', 'e.teamId')
|
||||
.on('e.deletedAt', 'is', null)
|
||||
.on('e.type', '=', sql.lit(EnvelopeType.DOCUMENT)),
|
||||
)
|
||||
.leftJoin('TeamGroup as tg', 'tg.teamId', 't.id')
|
||||
.leftJoin('OrganisationGroup as og', 'og.id', 'tg.organisationGroupId')
|
||||
.leftJoin('OrganisationGroupMember as ogm', 'ogm.groupId', 'og.id')
|
||||
.leftJoin('OrganisationMember as om', 'om.id', 'ogm.organisationMemberId')
|
||||
.where('t.organisationId', '=', organisationId)
|
||||
.select((eb) => [
|
||||
't.id',
|
||||
't.name',
|
||||
't.createdAt',
|
||||
eb
|
||||
.selectFrom('TeamGroup as tg')
|
||||
.innerJoin('OrganisationGroup as og', 'og.id', 'tg.organisationGroupId')
|
||||
.innerJoin('OrganisationGroupMember as ogm', 'ogm.groupId', 'og.id')
|
||||
.innerJoin('OrganisationMember as om', 'om.id', 'ogm.organisationMemberId')
|
||||
.whereRef('tg.teamId', '=', 't.id')
|
||||
.select(sql<number>`count(distinct om."userId")`.as('count'))
|
||||
.as('memberCount'),
|
||||
eb
|
||||
.selectFrom('Envelope as e')
|
||||
.whereRef('e.teamId', '=', 't.id')
|
||||
.where('e.deletedAt', 'is', null)
|
||||
.where('e.type', '=', sql.lit(EnvelopeType.DOCUMENT))
|
||||
.$if(!!createdAtFrom, (qb) => qb.where('e.createdAt', '>=', createdAtFrom!))
|
||||
.select(sql<number>`count(e.id)`.as('count'))
|
||||
.as('documentCount'),
|
||||
.select([
|
||||
't.id as id',
|
||||
't.name as name',
|
||||
't.createdAt as createdAt',
|
||||
sql<number>`COUNT(DISTINCT om."userId")`.as('memberCount'),
|
||||
(createdAtFrom
|
||||
? sql<number>`COUNT(DISTINCT CASE WHEN e.id IS NOT NULL AND e."createdAt" >= ${createdAtFrom} THEN e.id END)`
|
||||
: sql<number>`COUNT(DISTINCT e.id)`
|
||||
).as('documentCount'),
|
||||
])
|
||||
.groupBy(['t.id', 't.name', 't.createdAt'])
|
||||
.orderBy('documentCount', 'desc')
|
||||
.limit(perPage)
|
||||
.offset(offset);
|
||||
@ -164,38 +164,48 @@ async function getUserInsights(
|
||||
perPage: number,
|
||||
createdAtFrom: Date | null,
|
||||
): Promise<OrganisationDetailedInsights> {
|
||||
const usersQuery = kyselyPrisma.$kysely
|
||||
const usersBase = kyselyPrisma.$kysely
|
||||
.selectFrom('OrganisationMember as om')
|
||||
.innerJoin('User as u', 'u.id', 'om.userId')
|
||||
.where('om.organisationId', '=', organisationId)
|
||||
.select((eb) => [
|
||||
'u.id',
|
||||
'u.name',
|
||||
'u.email',
|
||||
'u.createdAt',
|
||||
eb
|
||||
.selectFrom('Envelope as e')
|
||||
.innerJoin('Team as t', 't.id', 'e.teamId')
|
||||
.whereRef('e.userId', '=', 'u.id')
|
||||
.where('t.organisationId', '=', organisationId)
|
||||
.where('e.deletedAt', 'is', null)
|
||||
.where('e.type', '=', sql.lit(EnvelopeType.DOCUMENT))
|
||||
.$if(!!createdAtFrom, (qb) => qb.where('e.createdAt', '>=', createdAtFrom!))
|
||||
.select(sql<number>`count(e.id)`.as('count'))
|
||||
.as('documentCount'),
|
||||
eb
|
||||
.selectFrom('Recipient as r')
|
||||
.innerJoin('Envelope as e', 'e.id', 'r.envelopeId')
|
||||
.innerJoin('Team as t', 't.id', 'e.teamId')
|
||||
.whereRef('r.email', '=', 'u.email')
|
||||
.where('r.signedAt', 'is not', null)
|
||||
.where('t.organisationId', '=', organisationId)
|
||||
.where('e.deletedAt', 'is', null)
|
||||
.where('e.type', '=', sql.lit(EnvelopeType.DOCUMENT))
|
||||
.$if(!!createdAtFrom, (qb) => qb.where('e.createdAt', '>=', createdAtFrom!))
|
||||
.select(sql<number>`count(e.id)`.as('count'))
|
||||
.as('signedDocumentCount'),
|
||||
.leftJoin('Envelope as e', (join) =>
|
||||
join
|
||||
.onRef('e.userId', '=', 'u.id')
|
||||
.on('e.deletedAt', 'is', null)
|
||||
.on('e.type', '=', sql.lit(EnvelopeType.DOCUMENT)),
|
||||
)
|
||||
.leftJoin('Team as td', (join) =>
|
||||
join.onRef('td.id', '=', 'e.teamId').on('td.organisationId', '=', organisationId),
|
||||
)
|
||||
.leftJoin('Recipient as r', (join) =>
|
||||
join.onRef('r.email', '=', 'u.email').on('r.signedAt', 'is not', null),
|
||||
)
|
||||
.leftJoin('Envelope as se', (join) =>
|
||||
join
|
||||
.onRef('se.id', '=', 'r.envelopeId')
|
||||
.on('se.deletedAt', 'is', null)
|
||||
.on('se.type', '=', sql.lit(EnvelopeType.DOCUMENT)),
|
||||
)
|
||||
.leftJoin('Team as ts', (join) =>
|
||||
join.onRef('ts.id', '=', 'se.teamId').on('ts.organisationId', '=', organisationId),
|
||||
);
|
||||
|
||||
const usersQuery = usersBase
|
||||
.select([
|
||||
'u.id as id',
|
||||
'u.name as name',
|
||||
'u.email as email',
|
||||
'u.createdAt as createdAt',
|
||||
(createdAtFrom
|
||||
? sql<number>`COUNT(DISTINCT CASE WHEN e.id IS NOT NULL AND td.id IS NOT NULL AND e."createdAt" >= ${createdAtFrom} THEN e.id END)`
|
||||
: sql<number>`COUNT(DISTINCT CASE WHEN td.id IS NOT NULL THEN e.id END)`
|
||||
).as('documentCount'),
|
||||
(createdAtFrom
|
||||
? sql<number>`COUNT(DISTINCT CASE WHEN e.id IS NOT NULL AND td.id IS NOT NULL AND e.status = 'COMPLETED' AND e."createdAt" >= ${createdAtFrom} THEN e.id END)`
|
||||
: sql<number>`COUNT(DISTINCT CASE WHEN e.id IS NOT NULL AND td.id IS NOT NULL AND e.status = 'COMPLETED' THEN e.id END)`
|
||||
).as('signedDocumentCount'),
|
||||
])
|
||||
.groupBy(['u.id', 'u.name', 'u.email', 'u.createdAt'])
|
||||
.orderBy('u.createdAt', 'desc')
|
||||
.limit(perPage)
|
||||
.offset(offset);
|
||||
@ -282,51 +292,72 @@ async function getOrganisationSummary(
|
||||
organisationId: string,
|
||||
createdAtFrom: Date | null,
|
||||
): Promise<OrganisationSummary> {
|
||||
const teamCountQuery = kyselyPrisma.$kysely
|
||||
.selectFrom('Team')
|
||||
.where('organisationId', '=', organisationId)
|
||||
.select(sql<number>`count(id)`.as('count'))
|
||||
.executeTakeFirst();
|
||||
|
||||
const memberCountQuery = kyselyPrisma.$kysely
|
||||
.selectFrom('OrganisationMember')
|
||||
.where('organisationId', '=', organisationId)
|
||||
.select(sql<number>`count(id)`.as('count'))
|
||||
.executeTakeFirst();
|
||||
|
||||
const envelopeStatsQuery = kyselyPrisma.$kysely
|
||||
.selectFrom('Envelope as e')
|
||||
.innerJoin('Team as t', 't.id', 'e.teamId')
|
||||
.where('t.organisationId', '=', organisationId)
|
||||
.where('e.deletedAt', 'is', null)
|
||||
.where('e.type', '=', sql.lit(EnvelopeType.DOCUMENT))
|
||||
const summaryQuery = kyselyPrisma.$kysely
|
||||
.selectFrom('Organisation as o')
|
||||
.where('o.id', '=', organisationId)
|
||||
.select([
|
||||
sql<number>`count(e.id)`.as('totalDocuments'),
|
||||
sql<number>`count(case when e.status in ('DRAFT', 'PENDING') then 1 end)`.as(
|
||||
'activeDocuments',
|
||||
sql<number>`(SELECT COUNT(DISTINCT t2.id) FROM "Team" AS t2 WHERE t2."organisationId" = o.id)`.as(
|
||||
'totalTeams',
|
||||
),
|
||||
sql<number>`count(case when e.status = 'COMPLETED' then 1 end)`.as('completedDocuments'),
|
||||
sql<number>`count(case when e.status = 'COMPLETED' then 1 end)`.as('volumeAllTime'),
|
||||
sql<number>`(SELECT COUNT(DISTINCT om2."userId") FROM "OrganisationMember" AS om2 WHERE om2."organisationId" = o.id)`.as(
|
||||
'totalMembers',
|
||||
),
|
||||
sql<number>`(
|
||||
SELECT COUNT(DISTINCT e2.id)
|
||||
FROM "Envelope" AS e2
|
||||
INNER JOIN "Team" AS t2 ON t2.id = e2."teamId"
|
||||
WHERE t2."organisationId" = o.id AND e2."deletedAt" IS NULL AND e2.type = 'DOCUMENT'
|
||||
)`.as('totalDocuments'),
|
||||
sql<number>`(
|
||||
SELECT COUNT(DISTINCT e2.id)
|
||||
FROM "Envelope" AS e2
|
||||
INNER JOIN "Team" AS t2 ON t2.id = e2."teamId"
|
||||
WHERE t2."organisationId" = o.id AND e2."deletedAt" IS NULL AND e2.type = 'DOCUMENT' AND e2.status IN ('DRAFT', 'PENDING')
|
||||
)`.as('activeDocuments'),
|
||||
sql<number>`(
|
||||
SELECT COUNT(DISTINCT e2.id)
|
||||
FROM "Envelope" AS e2
|
||||
INNER JOIN "Team" AS t2 ON t2.id = e2."teamId"
|
||||
WHERE t2."organisationId" = o.id AND e2."deletedAt" IS NULL AND e2.type = 'DOCUMENT' AND e2.status = 'COMPLETED'
|
||||
)`.as('completedDocuments'),
|
||||
(createdAtFrom
|
||||
? sql<number>`count(case when e.status = 'COMPLETED' and e."createdAt" >= ${createdAtFrom} then 1 end)`
|
||||
: sql<number>`count(case when e.status = 'COMPLETED' then 1 end)`
|
||||
? sql<number>`(
|
||||
SELECT COUNT(DISTINCT e2.id)
|
||||
FROM "Envelope" AS e2
|
||||
INNER JOIN "Team" AS t2 ON t2.id = e2."teamId"
|
||||
WHERE t2."organisationId" = o.id
|
||||
AND e2."deletedAt" IS NULL
|
||||
AND e2.type = 'DOCUMENT'
|
||||
AND e2.status = 'COMPLETED'
|
||||
AND e2."createdAt" >= ${createdAtFrom}
|
||||
)`
|
||||
: sql<number>`(
|
||||
SELECT COUNT(DISTINCT e2.id)
|
||||
FROM "Envelope" AS e2
|
||||
INNER JOIN "Team" AS t2 ON t2.id = e2."teamId"
|
||||
WHERE t2."organisationId" = o.id
|
||||
AND e2."deletedAt" IS NULL
|
||||
AND e2.type = 'DOCUMENT'
|
||||
AND e2.status = 'COMPLETED'
|
||||
)`
|
||||
).as('volumeThisPeriod'),
|
||||
])
|
||||
.executeTakeFirst();
|
||||
sql<number>`(
|
||||
SELECT COUNT(DISTINCT e2.id)
|
||||
FROM "Envelope" AS e2
|
||||
INNER JOIN "Team" AS t2 ON t2.id = e2."teamId"
|
||||
WHERE t2."organisationId" = o.id AND e2."deletedAt" IS NULL AND e2.type = 'DOCUMENT' AND e2.status = 'COMPLETED'
|
||||
)`.as('volumeAllTime'),
|
||||
]);
|
||||
|
||||
const [teamCount, memberCount, envelopeStats] = await Promise.all([
|
||||
teamCountQuery,
|
||||
memberCountQuery,
|
||||
envelopeStatsQuery,
|
||||
]);
|
||||
const result = await summaryQuery.executeTakeFirst();
|
||||
|
||||
return {
|
||||
totalTeams: Number(teamCount?.count || 0),
|
||||
totalMembers: Number(memberCount?.count || 0),
|
||||
totalDocuments: Number(envelopeStats?.totalDocuments || 0),
|
||||
activeDocuments: Number(envelopeStats?.activeDocuments || 0),
|
||||
completedDocuments: Number(envelopeStats?.completedDocuments || 0),
|
||||
volumeThisPeriod: Number(envelopeStats?.volumeThisPeriod || 0),
|
||||
volumeAllTime: Number(envelopeStats?.volumeAllTime || 0),
|
||||
totalTeams: Number(result?.totalTeams || 0),
|
||||
totalMembers: Number(result?.totalMembers || 0),
|
||||
totalDocuments: Number(result?.totalDocuments || 0),
|
||||
activeDocuments: Number(result?.activeDocuments || 0),
|
||||
completedDocuments: Number(result?.completedDocuments || 0),
|
||||
volumeThisPeriod: Number(result?.volumeThisPeriod || 0),
|
||||
volumeAllTime: Number(result?.volumeAllTime || 0),
|
||||
};
|
||||
}
|
||||
|
||||
@ -33,32 +33,25 @@ export async function getSigningVolume({
|
||||
|
||||
let findQuery = kyselyPrisma.$kysely
|
||||
.selectFrom('Organisation as o')
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb('o.name', 'ilike', `%${search}%`),
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('Team as t')
|
||||
.whereRef('t.organisationId', '=', 'o.id')
|
||||
.where('t.name', 'ilike', `%${search}%`),
|
||||
),
|
||||
]),
|
||||
.leftJoin('Team as t', 'o.id', 't.organisationId')
|
||||
.leftJoin('Envelope as e', (join) =>
|
||||
join
|
||||
.onRef('t.id', '=', 'e.teamId')
|
||||
.on('e.status', '=', sql.lit(DocumentStatus.COMPLETED))
|
||||
.on('e.deletedAt', 'is', null)
|
||||
.on('e.type', '=', sql.lit(EnvelopeType.DOCUMENT)),
|
||||
)
|
||||
.select((eb) => [
|
||||
.where((eb) =>
|
||||
eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]),
|
||||
)
|
||||
.select([
|
||||
'o.id as id',
|
||||
'o.createdAt as createdAt',
|
||||
'o.customerId as customerId',
|
||||
sql<string>`COALESCE(o.name, 'Unknown')`.as('name'),
|
||||
eb
|
||||
.selectFrom('Envelope as e')
|
||||
.innerJoin('Team as t', 't.id', 'e.teamId')
|
||||
.whereRef('t.organisationId', '=', 'o.id')
|
||||
.where('e.status', '=', sql.lit(DocumentStatus.COMPLETED))
|
||||
.where('e.deletedAt', 'is', null)
|
||||
.where('e.type', '=', sql.lit(EnvelopeType.DOCUMENT))
|
||||
.select(sql<number>`count(e.id)`.as('count'))
|
||||
.as('signingVolume'),
|
||||
]);
|
||||
sql<number>`COUNT(DISTINCT e.id)`.as('signingVolume'),
|
||||
])
|
||||
.groupBy(['o.id', 'o.name', 'o.customerId']);
|
||||
|
||||
switch (sortBy) {
|
||||
case 'name':
|
||||
@ -78,18 +71,11 @@ export async function getSigningVolume({
|
||||
|
||||
const countQuery = kyselyPrisma.$kysely
|
||||
.selectFrom('Organisation as o')
|
||||
.leftJoin('Team as t', 'o.id', 't.organisationId')
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb('o.name', 'ilike', `%${search}%`),
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('Team as t')
|
||||
.whereRef('t.organisationId', '=', 'o.id')
|
||||
.where('t.name', 'ilike', `%${search}%`),
|
||||
),
|
||||
]),
|
||||
eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]),
|
||||
)
|
||||
.select(({ fn }) => [fn.countAll().as('count')]);
|
||||
.select(() => [sql<number>`COUNT(DISTINCT o.id)`.as('count')]);
|
||||
|
||||
const [results, [{ count }]] = await Promise.all([findQuery.execute(), countQuery.execute()]);
|
||||
|
||||
@ -118,77 +104,64 @@ export async function getOrganisationInsights({
|
||||
const offset = Math.max(page - 1, 0) * perPage;
|
||||
|
||||
const now = new Date();
|
||||
let dateCondition = sql<boolean>`1=1`;
|
||||
let dateCondition = sql`1=1`;
|
||||
|
||||
if (startDate && endDate) {
|
||||
dateCondition = sql<boolean>`e."createdAt" >= ${startDate} AND e."createdAt" <= ${endDate}`;
|
||||
dateCondition = sql`e."createdAt" >= ${startDate} AND e."createdAt" <= ${endDate}`;
|
||||
} else {
|
||||
switch (dateRange) {
|
||||
case 'last30days': {
|
||||
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
dateCondition = sql<boolean>`e."createdAt" >= ${thirtyDaysAgo}`;
|
||||
dateCondition = sql`e."createdAt" >= ${thirtyDaysAgo}`;
|
||||
break;
|
||||
}
|
||||
case 'last90days': {
|
||||
const ninetyDaysAgo = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
|
||||
dateCondition = sql<boolean>`e."createdAt" >= ${ninetyDaysAgo}`;
|
||||
dateCondition = sql`e."createdAt" >= ${ninetyDaysAgo}`;
|
||||
break;
|
||||
}
|
||||
case 'lastYear': {
|
||||
const oneYearAgo = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate());
|
||||
dateCondition = sql<boolean>`e."createdAt" >= ${oneYearAgo}`;
|
||||
dateCondition = sql`e."createdAt" >= ${oneYearAgo}`;
|
||||
break;
|
||||
}
|
||||
case 'allTime':
|
||||
default:
|
||||
dateCondition = sql<boolean>`1=1`;
|
||||
dateCondition = sql`1=1`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let findQuery = kyselyPrisma.$kysely
|
||||
.selectFrom('Organisation as o')
|
||||
.leftJoin('Team as t', 'o.id', 't.organisationId')
|
||||
.leftJoin('Envelope as e', (join) =>
|
||||
join
|
||||
.onRef('t.id', '=', 'e.teamId')
|
||||
.on('e.status', '=', sql.lit(DocumentStatus.COMPLETED))
|
||||
.on('e.deletedAt', 'is', null)
|
||||
.on('e.type', '=', sql.lit(EnvelopeType.DOCUMENT)),
|
||||
)
|
||||
.leftJoin('OrganisationMember as om', 'o.id', 'om.organisationId')
|
||||
.leftJoin('Subscription as s', 'o.id', 's.organisationId')
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb('o.name', 'ilike', `%${search}%`),
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('Team as t')
|
||||
.whereRef('t.organisationId', '=', 'o.id')
|
||||
.where('t.name', 'ilike', `%${search}%`),
|
||||
),
|
||||
]),
|
||||
eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]),
|
||||
)
|
||||
.select((eb) => [
|
||||
.select([
|
||||
'o.id as id',
|
||||
'o.createdAt as createdAt',
|
||||
'o.customerId as customerId',
|
||||
sql<string>`COALESCE(o.name, 'Unknown')`.as('name'),
|
||||
sql<number>`COUNT(DISTINCT CASE WHEN e.id IS NOT NULL AND ${dateCondition} THEN e.id END)`.as(
|
||||
'signingVolume',
|
||||
),
|
||||
sql<number>`GREATEST(COUNT(DISTINCT t.id), 1)`.as('teamCount'),
|
||||
sql<number>`COUNT(DISTINCT om."userId")`.as('memberCount'),
|
||||
sql<string>`CASE WHEN s.status IS NOT NULL THEN s.status ELSE NULL END`.as(
|
||||
'subscriptionStatus',
|
||||
),
|
||||
eb
|
||||
.selectFrom('Team as t')
|
||||
.whereRef('t.organisationId', '=', 'o.id')
|
||||
.select(sql<number>`count(t.id)`.as('count'))
|
||||
.as('teamCount'),
|
||||
eb
|
||||
.selectFrom('OrganisationMember as om')
|
||||
.whereRef('om.organisationId', '=', 'o.id')
|
||||
.select(sql<number>`count(om.id)`.as('count'))
|
||||
.as('memberCount'),
|
||||
eb
|
||||
.selectFrom('Envelope as e')
|
||||
.innerJoin('Team as t', 't.id', 'e.teamId')
|
||||
.whereRef('t.organisationId', '=', 'o.id')
|
||||
.where('e.status', '=', sql.lit(DocumentStatus.COMPLETED))
|
||||
.where('e.deletedAt', 'is', null)
|
||||
.where('e.type', '=', sql.lit(EnvelopeType.DOCUMENT))
|
||||
.where(dateCondition)
|
||||
.select(sql<number>`count(e.id)`.as('count'))
|
||||
.as('signingVolume'),
|
||||
]);
|
||||
])
|
||||
.groupBy(['o.id', 'o.name', 'o.customerId', 's.status']);
|
||||
|
||||
switch (sortBy) {
|
||||
case 'name':
|
||||
@ -208,18 +181,11 @@ export async function getOrganisationInsights({
|
||||
|
||||
const countQuery = kyselyPrisma.$kysely
|
||||
.selectFrom('Organisation as o')
|
||||
.leftJoin('Team as t', 'o.id', 't.organisationId')
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb('o.name', 'ilike', `%${search}%`),
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('Team as t')
|
||||
.whereRef('t.organisationId', '=', 'o.id')
|
||||
.where('t.name', 'ilike', `%${search}%`),
|
||||
),
|
||||
]),
|
||||
eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]),
|
||||
)
|
||||
.select(({ fn }) => [fn.countAll().as('count')]);
|
||||
.select(() => [sql<number>`COUNT(DISTINCT o.id)`.as('count')]);
|
||||
|
||||
const [results, [{ count }]] = await Promise.all([findQuery.execute(), countQuery.execute()]);
|
||||
|
||||
|
||||
15
packages/lib/server-only/image/resize-and-compress-image.ts
Normal file
15
packages/lib/server-only/image/resize-and-compress-image.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import sharp from 'sharp';
|
||||
|
||||
export const resizeAndCompressImage = async (imageBuffer: Buffer): Promise<Buffer> => {
|
||||
const metadata = await sharp(imageBuffer).metadata();
|
||||
const originalWidth = metadata.width || 0;
|
||||
|
||||
if (originalWidth > 1000) {
|
||||
return await sharp(imageBuffer)
|
||||
.resize({ width: 1000, withoutEnlargement: true })
|
||||
.jpeg({ quality: 70 })
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
return await sharp(imageBuffer).jpeg({ quality: 70 }).toBuffer();
|
||||
};
|
||||
61
packages/lib/server-only/pdf/render-pdf-to-image.ts
Normal file
61
packages/lib/server-only/pdf/render-pdf-to-image.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import { createRequire } from 'node:module';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { Canvas, Image } from 'skia-canvas';
|
||||
|
||||
const require = createRequire(import.meta.url || fileURLToPath(new URL('.', import.meta.url)));
|
||||
const Module = require('node:module');
|
||||
|
||||
const originalRequire = Module.prototype.require;
|
||||
Module.prototype.require = function (path: string) {
|
||||
if (path === 'canvas') {
|
||||
return {
|
||||
createCanvas: (width: number, height: number) => new Canvas(width, height),
|
||||
Image, // needed by pdfjs-dist
|
||||
};
|
||||
}
|
||||
// eslint-disable-next-line prefer-rest-params, @typescript-eslint/consistent-type-assertions
|
||||
return originalRequire.apply(this, arguments as unknown as [string]);
|
||||
};
|
||||
|
||||
// Use dynamic require to bypass Vite SSR transformation
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const pdfjsLib = require('pdfjs-dist/legacy/build/pdf.js');
|
||||
|
||||
export const renderPdfToImage = async (pdfBytes: Uint8Array) => {
|
||||
const loadingTask = pdfjsLib.getDocument({ data: pdfBytes });
|
||||
const pdf = await loadingTask.promise;
|
||||
|
||||
try {
|
||||
const scale = 2;
|
||||
|
||||
const pages = await Promise.all(
|
||||
Array.from({ length: pdf.numPages }, async (_, index) => {
|
||||
const pageNumber = index + 1;
|
||||
const page = await pdf.getPage(pageNumber);
|
||||
|
||||
try {
|
||||
const viewport = page.getViewport({ scale });
|
||||
|
||||
const virtualCanvas = new Canvas(viewport.width, viewport.height);
|
||||
const context = virtualCanvas.getContext('2d');
|
||||
context.imageSmoothingEnabled = false;
|
||||
|
||||
await page.render({ canvasContext: context, viewport }).promise;
|
||||
|
||||
return {
|
||||
image: await virtualCanvas.toBuffer('png'),
|
||||
pageNumber,
|
||||
width: Math.floor(viewport.width),
|
||||
height: Math.floor(viewport.height),
|
||||
};
|
||||
} finally {
|
||||
page.cleanup();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return pages;
|
||||
} finally {
|
||||
await pdf.destroy();
|
||||
}
|
||||
};
|
||||
@ -12,10 +12,10 @@ 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 {
|
||||
export type CreateEnvelopeRecipientsOptions = {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
id: EnvelopeIdOptions;
|
||||
@ -28,7 +28,7 @@ export interface CreateEnvelopeRecipientsOptions {
|
||||
actionAuth?: TRecipientActionAuthTypes[];
|
||||
}[];
|
||||
requestMetadata: ApiRequestMetadata;
|
||||
}
|
||||
};
|
||||
|
||||
export const createEnvelopeRecipients = async ({
|
||||
userId,
|
||||
@ -85,6 +85,7 @@ export const createEnvelopeRecipients = async ({
|
||||
|
||||
const normalizedRecipients = recipientsToCreate.map((recipient) => ({
|
||||
...recipient,
|
||||
name: sanitizeRecipientName(recipient.name),
|
||||
email: recipient.email.toLowerCase(),
|
||||
}));
|
||||
|
||||
|
||||
@ -28,18 +28,18 @@ 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';
|
||||
|
||||
export interface SetDocumentRecipientsOptions {
|
||||
export type SetDocumentRecipientsOptions = {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
id: EnvelopeIdOptions;
|
||||
recipients: RecipientData[];
|
||||
requestMetadata: ApiRequestMetadata;
|
||||
}
|
||||
};
|
||||
|
||||
export const setDocumentRecipients = async ({
|
||||
userId,
|
||||
@ -114,6 +114,7 @@ export const setDocumentRecipients = async ({
|
||||
|
||||
const normalizedRecipients = recipients.map((recipient) => ({
|
||||
...recipient,
|
||||
name: sanitizeRecipientName(recipient.name),
|
||||
email: recipient.email.toLowerCase(),
|
||||
}));
|
||||
|
||||
|
||||
@ -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(),
|
||||
};
|
||||
});
|
||||
|
||||
@ -18,10 +18,10 @@ 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 {
|
||||
export type UpdateEnvelopeRecipientsOptions = {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
id: EnvelopeIdOptions;
|
||||
@ -35,7 +35,7 @@ export interface UpdateEnvelopeRecipientsOptions {
|
||||
actionAuth?: TRecipientActionAuthTypes[];
|
||||
}[];
|
||||
requestMetadata: ApiRequestMetadata;
|
||||
}
|
||||
};
|
||||
|
||||
export const updateEnvelopeRecipients = async ({
|
||||
userId,
|
||||
@ -108,9 +108,18 @@ export const updateEnvelopeRecipients = async ({
|
||||
});
|
||||
}
|
||||
|
||||
const sanitizedUpdateData = {
|
||||
...recipient,
|
||||
...(recipient.name !== undefined
|
||||
? {
|
||||
name: sanitizeRecipientName(recipient.name),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
return {
|
||||
originalRecipient,
|
||||
updateData: recipient,
|
||||
updateData: sanitizedUpdateData,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
16
packages/lib/types/document-analysis.ts
Normal file
16
packages/lib/types/document-analysis.ts
Normal file
@ -0,0 +1,16 @@
|
||||
export type TDetectedFormField = {
|
||||
boundingBox: number[];
|
||||
label:
|
||||
| 'SIGNATURE'
|
||||
| 'INITIALS'
|
||||
| 'NAME'
|
||||
| 'EMAIL'
|
||||
| 'DATE'
|
||||
| 'TEXT'
|
||||
| 'NUMBER'
|
||||
| 'RADIO'
|
||||
| 'CHECKBOX'
|
||||
| 'DROPDOWN';
|
||||
pageNumber: number;
|
||||
recipientId: number;
|
||||
};
|
||||
@ -63,7 +63,10 @@ export const ZEnvelopeSchema = EnvelopeSchema.pick({
|
||||
id: true,
|
||||
title: true,
|
||||
order: true,
|
||||
}).array(),
|
||||
documentDataId: true,
|
||||
})
|
||||
.partial({ documentDataId: true })
|
||||
.array(),
|
||||
directLink: TemplateDirectLinkSchema.pick({
|
||||
directTemplateRecipientId: true,
|
||||
enabled: true,
|
||||
|
||||
@ -303,6 +303,7 @@ export const FIELD_NUMBER_META_DEFAULT_VALUES: TNumberFieldMeta = {
|
||||
textAlign: 'left',
|
||||
label: '',
|
||||
placeholder: '',
|
||||
value: '',
|
||||
required: false,
|
||||
readOnly: false,
|
||||
};
|
||||
|
||||
@ -27,7 +27,7 @@ if (loggingFilePath) {
|
||||
}
|
||||
|
||||
export const logger = pino({
|
||||
level: 'info',
|
||||
level: env('LOG_LEVEL') || 'info',
|
||||
transport:
|
||||
transports.length > 0
|
||||
? {
|
||||
|
||||
@ -1,11 +1,26 @@
|
||||
import type { Envelope } from '@prisma/client';
|
||||
import { type Field, type Recipient, RecipientRole, SigningStatus } from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
|
||||
import { extractLegacyIds } from '../universal/id';
|
||||
|
||||
const UNKNOWN_RECIPIENT_NAME_PLACEHOLDER = '<UNKNOWN>';
|
||||
|
||||
export const formatSigningLink = (token: string) => `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${token}`;
|
||||
|
||||
export const resolveRecipientEmail = (candidateEmail: string | undefined | null) => {
|
||||
if (candidateEmail) {
|
||||
const trimmedEmail = candidateEmail.trim();
|
||||
|
||||
if (z.string().email().safeParse(trimmedEmail).success) {
|
||||
return trimmedEmail;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether a recipient can be modified by the document owner.
|
||||
*/
|
||||
@ -58,3 +73,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;
|
||||
};
|
||||
|
||||
@ -1,29 +0,0 @@
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Envelope_teamId_deletedAt_type_status_idx" ON "Envelope"("teamId", "deletedAt", "type", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Envelope_teamId_deletedAt_type_createdAt_idx" ON "Envelope"("teamId", "deletedAt", "type", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Envelope_userId_deletedAt_type_idx" ON "Envelope"("userId", "deletedAt", "type");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Envelope_status_deletedAt_type_idx" ON "Envelope"("status", "deletedAt", "type");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Organisation_name_idx" ON "Organisation"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "OrganisationMember_organisationId_idx" ON "OrganisationMember"("organisationId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Recipient_email_idx" ON "Recipient"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Recipient_signedAt_idx" ON "Recipient"("signedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Recipient_envelopeId_signedAt_idx" ON "Recipient"("envelopeId", "signedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Team_organisationId_name_idx" ON "Team"("organisationId", "name");
|
||||
@ -433,10 +433,6 @@ model Envelope {
|
||||
@@index([folderId])
|
||||
@@index([teamId])
|
||||
@@index([userId])
|
||||
@@index([teamId, deletedAt, type, status])
|
||||
@@index([teamId, deletedAt, type, createdAt])
|
||||
@@index([userId, deletedAt, type])
|
||||
@@index([status, deletedAt, type])
|
||||
}
|
||||
|
||||
model EnvelopeItem {
|
||||
@ -589,9 +585,6 @@ model Recipient {
|
||||
|
||||
@@index([envelopeId])
|
||||
@@index([token])
|
||||
@@index([email])
|
||||
@@index([signedAt])
|
||||
@@index([envelopeId, signedAt])
|
||||
}
|
||||
|
||||
enum FieldType {
|
||||
@ -701,8 +694,6 @@ model Organisation {
|
||||
|
||||
organisationAuthenticationPortalId String @unique
|
||||
organisationAuthenticationPortal OrganisationAuthenticationPortal @relation(fields: [organisationAuthenticationPortalId], references: [id])
|
||||
|
||||
@@index([name])
|
||||
}
|
||||
|
||||
model OrganisationMember {
|
||||
@ -719,7 +710,6 @@ model OrganisationMember {
|
||||
organisationGroupMembers OrganisationGroupMember[]
|
||||
|
||||
@@unique([userId, organisationId])
|
||||
@@index([organisationId])
|
||||
}
|
||||
|
||||
model OrganisationMemberInvite {
|
||||
@ -894,7 +884,6 @@ model Team {
|
||||
teamGlobalSettings TeamGlobalSettings @relation(fields: [teamGlobalSettingsId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([organisationId])
|
||||
@@index([organisationId, name])
|
||||
}
|
||||
|
||||
model TeamEmail {
|
||||
|
||||
@ -142,11 +142,17 @@ module.exports = {
|
||||
'0%,70%,100%': { opacity: '1' },
|
||||
'20%,50%': { opacity: '0' },
|
||||
},
|
||||
scan: {
|
||||
'0%': { top: '0%' },
|
||||
'50%': { top: '95%' },
|
||||
'100%': { top: '0%' },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
'accordion-down': 'accordion-down 0.2s ease-out',
|
||||
'accordion-up': 'accordion-up 0.2s ease-out',
|
||||
'caret-blink': 'caret-blink 1.25s ease-out infinite',
|
||||
scan: 'scan 4s linear infinite',
|
||||
},
|
||||
screens: {
|
||||
'3xl': '1920px',
|
||||
|
||||
5
packages/tsconfig/process-env.d.ts
vendored
5
packages/tsconfig/process-env.d.ts
vendored
@ -80,6 +80,11 @@ declare namespace NodeJS {
|
||||
NEXT_PRIVATE_INNGEST_APP_ID?: string;
|
||||
NEXT_PRIVATE_INNGEST_EVENT_KEY?: string;
|
||||
|
||||
/**
|
||||
* Google Generative AI (Gemini)
|
||||
*/
|
||||
GOOGLE_GENERATIVE_AI_API_KEY?: string;
|
||||
|
||||
POSTGRES_URL?: string;
|
||||
DATABASE_URL?: string;
|
||||
POSTGRES_PRISMA_URL?: string;
|
||||
|
||||
@ -197,6 +197,56 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes edgeGlow {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.edge-glow {
|
||||
animation: edgeGlow 1.25s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.edge-glow-top {
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(162, 231, 113, 0.8) 0%,
|
||||
rgba(162, 231, 113, 0.4) 20%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
.edge-glow-right {
|
||||
background: linear-gradient(
|
||||
to left,
|
||||
rgba(162, 231, 113, 0.8) 0%,
|
||||
rgba(162, 231, 113, 0.4) 20%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
.edge-glow-bottom {
|
||||
background: linear-gradient(
|
||||
to top,
|
||||
rgba(162, 231, 113, 0.8) 0%,
|
||||
rgba(162, 231, 113, 0.4) 20%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
.edge-glow-left {
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
rgba(162, 231, 113, 0.8) 0%,
|
||||
rgba(162, 231, 113, 0.4) 20%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* Custom CSS for printing reports
|
||||
* - Sets page margins to 0.5 inches
|
||||
|
||||
@ -46,6 +46,7 @@
|
||||
"NEXT_PUBLIC_WEBAPP_URL",
|
||||
"NEXT_PRIVATE_INTERNAL_WEBAPP_URL",
|
||||
"NEXT_PUBLIC_POSTHOG_KEY",
|
||||
"NEXT_PUBLIC_AI_DEBUG_PREVIEW",
|
||||
"NEXT_PUBLIC_FEATURE_BILLING_ENABLED",
|
||||
"NEXT_PUBLIC_DISABLE_SIGNUP",
|
||||
"NEXT_PRIVATE_PLAIN_API_KEY",
|
||||
|
||||
Reference in New Issue
Block a user