mirror of
https://github.com/documenso/documenso.git
synced 2026-06-22 20:32:07 +10:00
Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f70082146 | |||
| 31ba6d5f00 | |||
| c4f89a87a2 | |||
| 89d6dd5b0e | |||
| 08a9ab3aaf | |||
| e66bd422e3 | |||
| 0f5814ff89 | |||
| 1275a15571 | |||
| 22d99c7410 | |||
| 26a36487d4 | |||
| 2ee6b90c99 | |||
| f70e6ac50a | |||
| 7a94ee3b83 | |||
| e39924714a | |||
| c9604fee64 | |||
| 90f8340af4 | |||
| 28b8d2d415 | |||
| 978a2047d4 | |||
| 0dfa953f54 | |||
| 4774324e07 | |||
| bc19699a58 | |||
| 55480826de | |||
| 327b0eaf86 | |||
| 2de5c1992f | |||
| df0c03816e | |||
| a610a06372 | |||
| d5e085d7ee | |||
| c322356654 |
+8
-7
@@ -148,12 +148,13 @@ NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS=false
|
||||
DOCUMENSO_DISABLE_TELEMETRY=
|
||||
|
||||
# [[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=
|
||||
# OPTIONAL: Google Cloud Project ID for Vertex AI.
|
||||
GOOGLE_VERTEX_PROJECT_ID=""
|
||||
# OPTIONAL: Google Cloud region for Vertex AI. Defaults to "global".
|
||||
GOOGLE_VERTEX_LOCATION="global"
|
||||
# OPTIONAL: API key for Google Vertex AI (Gemini). Get your key from:
|
||||
# https://console.cloud.google.com/vertex-ai/studio/settings/api-keys
|
||||
GOOGLE_VERTEX_API_KEY=""
|
||||
|
||||
# [[E2E Tests]]
|
||||
E2E_TEST_AUTHENTICATE_USERNAME="Test User"
|
||||
@@ -165,4 +166,4 @@ E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_Password123"
|
||||
NEXT_PRIVATE_LOGGER_FILE_PATH=
|
||||
|
||||
# [[PLAIN SUPPORT]]
|
||||
NEXT_PRIVATE_PLAIN_API_KEY=
|
||||
NEXT_PRIVATE_PLAIN_API_KEY=
|
||||
|
||||
+2
-2
@@ -61,5 +61,5 @@ CLAUDE.md
|
||||
# agents
|
||||
.specs
|
||||
|
||||
# ai debug previews
|
||||
packages/assets/ai-previews/
|
||||
# scripts
|
||||
scripts/output*
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
> 🚨 🚨 🚨
|
||||
> Documenso 2.0.0 is live on Product Hunt 🎉 <a href="https://documen.so/launch" target="_blank" rel="noopener noreferrer" style="text-decoration: underline;">Join us to celebrate the best Documenso yet 🪩</a>
|
||||
|
||||
<img src="https://github.com/documenso/documenso/assets/13398220/a643571f-0239-46a6-a73e-6bef38d1228b" alt="Documenso Logo">
|
||||
|
||||
<p align="center" style="margin-top: 20px">
|
||||
@@ -174,9 +171,11 @@ git clone https://github.com/<your-username>/documenso
|
||||
|
||||
5. Create the database schema by running `npm run prisma:migrate-dev`
|
||||
|
||||
6. Run `npm run dev` in the root directory to start
|
||||
6. Run `npm run translate:compile` in the root directory to compile lingui
|
||||
|
||||
7. Register a new user at http://localhost:3000/signup
|
||||
7. Run `npm run dev` in the root directory to start
|
||||
|
||||
8. Register a new user at http://localhost:3000/signup
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"@documenso/tailwind-config": "*",
|
||||
"@documenso/trpc": "*",
|
||||
"@documenso/ui": "*",
|
||||
"next": "^15",
|
||||
"next": "^15.5.7",
|
||||
"next-plausible": "^3.12.5",
|
||||
"nextra": "^3",
|
||||
"nextra-theme-docs": "^3",
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"dependencies": {
|
||||
"@documenso/prisma": "*",
|
||||
"luxon": "^3.7.2",
|
||||
"next": "^15"
|
||||
"next": "^15.5.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
|
||||
@@ -69,4 +69,3 @@
|
||||
--font-noto: 'Noto Sans', 'Noto Sans Korean', 'Noto Sans Japanese', 'Noto Sans Chinese';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ export const AdminDocumentDeleteDialog = ({ envelopeId }: AdminDocumentDeleteDia
|
||||
|
||||
toast({
|
||||
title: _(msg`Document deleted`),
|
||||
description: 'The Document has been deleted successfully.',
|
||||
description: _(msg`The Document has been deleted successfully.`),
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
@@ -54,8 +54,9 @@ export const AdminDocumentDeleteDialog = ({ envelopeId }: AdminDocumentDeleteDia
|
||||
toast({
|
||||
title: _(msg`An unknown error occurred`),
|
||||
variant: 'destructive',
|
||||
description:
|
||||
'We encountered an unknown error while attempting to delete your document. Please try again later.',
|
||||
description: _(
|
||||
msg`We encountered an unknown error while attempting to delete your document. Please try again later.`,
|
||||
),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { CheckIcon, FormInputIcon, ShieldCheckIcon } from 'lucide-react';
|
||||
|
||||
import type { NormalizedFieldWithContext } from '@documenso/lib/server-only/ai/envelope/detect-fields/types';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { Textarea } from '@documenso/ui/primitives/textarea';
|
||||
|
||||
import {
|
||||
AiApiError,
|
||||
type DetectFieldsProgressEvent,
|
||||
detectFields,
|
||||
} from '../../../server/api/ai/detect-fields.client';
|
||||
import { AnimatedDocumentScanner } from '../general/animated-document-scanner';
|
||||
|
||||
type DialogState = 'PROMPT' | 'PROCESSING' | 'REVIEW' | 'ERROR' | 'RATE_LIMITED';
|
||||
|
||||
type AiFieldDetectionDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onComplete: (fields: NormalizedFieldWithContext[]) => void;
|
||||
envelopeId: string;
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
const PROCESSING_MESSAGES = [
|
||||
msg`Reading your document`,
|
||||
msg`Analyzing page layout`,
|
||||
msg`Looking for form fields`,
|
||||
msg`Detecting signature areas`,
|
||||
msg`Identifying input fields`,
|
||||
msg`Mapping fields to recipients`,
|
||||
msg`Almost done`,
|
||||
] as const;
|
||||
|
||||
const FIELD_TYPE_LABELS: Record<string, MessageDescriptor> = {
|
||||
SIGNATURE: msg`Signature`,
|
||||
INITIALS: msg`Initials`,
|
||||
NAME: msg`Name`,
|
||||
EMAIL: msg`Email`,
|
||||
DATE: msg`Date`,
|
||||
TEXT: msg`Text`,
|
||||
NUMBER: msg`Number`,
|
||||
CHECKBOX: msg`Checkbox`,
|
||||
RADIO: msg`Radio`,
|
||||
};
|
||||
|
||||
export const AiFieldDetectionDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
onComplete,
|
||||
envelopeId,
|
||||
teamId,
|
||||
}: AiFieldDetectionDialogProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const [state, setState] = useState<DialogState>('PROMPT');
|
||||
const [messageIndex, setMessageIndex] = useState(0);
|
||||
const [detectedFields, setDetectedFields] = useState<NormalizedFieldWithContext[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [context, setContext] = useState('');
|
||||
const [progress, setProgress] = useState<DetectFieldsProgressEvent | null>(null);
|
||||
|
||||
const onDetectClick = useCallback(async () => {
|
||||
setState('PROCESSING');
|
||||
setMessageIndex(0);
|
||||
setError(null);
|
||||
setProgress(null);
|
||||
|
||||
try {
|
||||
await detectFields({
|
||||
request: {
|
||||
envelopeId,
|
||||
teamId,
|
||||
context: context || undefined,
|
||||
},
|
||||
onProgress: (progressEvent) => {
|
||||
setProgress(progressEvent);
|
||||
},
|
||||
onComplete: (event) => {
|
||||
setDetectedFields(event.fields);
|
||||
setState('REVIEW');
|
||||
},
|
||||
onError: (err) => {
|
||||
console.error('Detection failed:', err);
|
||||
|
||||
if (err.status === 429) {
|
||||
setState('RATE_LIMITED');
|
||||
return;
|
||||
}
|
||||
|
||||
setError(err.message);
|
||||
setState('ERROR');
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Detection failed:', err);
|
||||
|
||||
if (err instanceof AiApiError && err.status === 429) {
|
||||
setState('RATE_LIMITED');
|
||||
return;
|
||||
}
|
||||
|
||||
setError(err instanceof Error ? err.message : 'Failed to detect fields');
|
||||
setState('ERROR');
|
||||
}
|
||||
}, [envelopeId, teamId, context]);
|
||||
|
||||
const onAddFields = () => {
|
||||
onComplete(detectedFields);
|
||||
onOpenChange(false);
|
||||
setState('PROMPT');
|
||||
setDetectedFields([]);
|
||||
setContext('');
|
||||
};
|
||||
|
||||
const onClose = () => {
|
||||
onOpenChange(false);
|
||||
setState('PROMPT');
|
||||
setDetectedFields([]);
|
||||
setError(null);
|
||||
setContext('');
|
||||
setProgress(null);
|
||||
};
|
||||
|
||||
// Group fields by type for summary display
|
||||
const fieldCountsByType = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
|
||||
for (const field of detectedFields) {
|
||||
counts[field.type] = (counts[field.type] || 0) + 1;
|
||||
}
|
||||
|
||||
return Object.entries(counts).sort(([, a], [, b]) => b - a);
|
||||
}, [detectedFields]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state !== 'PROCESSING') {
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setMessageIndex((prev) => (prev + 1) % PROCESSING_MESSAGES.length);
|
||||
}, 4000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [state]);
|
||||
|
||||
return (
|
||||
<Dialog open={open}>
|
||||
<DialogContent className="sm:max-w-lg" hideClose={true}>
|
||||
{state === 'PROMPT' && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Detect fields</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>
|
||||
We'll scan your document to find form fields like signature lines, text inputs,
|
||||
checkboxes, and more. Detected fields will be suggested for you to review.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<Alert className="flex items-center gap-2 space-y-0" variant="neutral">
|
||||
<ShieldCheckIcon className="h-5 w-5 stroke-green-600" />
|
||||
<AlertDescription className="mt-0">
|
||||
<Trans>
|
||||
Your document is processed securely using AI services that don't retain your
|
||||
data.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="context">
|
||||
<Trans>Context</Trans>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="context"
|
||||
placeholder={_(msg`David is the Employee, Lucas is the Manager`)}
|
||||
value={context}
|
||||
onChange={(e) => setContext(e.target.value)}
|
||||
rows={2}
|
||||
className="resize-none"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<Trans>Help the AI assign fields to the right recipients.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
<Trans>Skip</Trans>
|
||||
</Button>
|
||||
<Button type="button" onClick={onDetectClick}>
|
||||
<Trans>Detect</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state === 'PROCESSING' && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Detecting fields</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col items-center py-8">
|
||||
<AnimatedDocumentScanner />
|
||||
|
||||
<p className="mt-8 text-muted-foreground">{_(PROCESSING_MESSAGES[messageIndex])}</p>
|
||||
|
||||
{progress && (
|
||||
<p className="mt-2 text-xs text-muted-foreground/60">
|
||||
<Trans>
|
||||
Page {progress.pagesProcessed} of {progress.totalPages} -{' '}
|
||||
{progress.fieldsDetected} field(s) found
|
||||
</Trans>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="mt-2 max-w-[40ch] text-center text-xs text-muted-foreground/60">
|
||||
<Trans>This can take a minute or two depending on the size of your document.</Trans>
|
||||
</p>
|
||||
|
||||
<div className="mt-4 flex gap-1">
|
||||
{PROCESSING_MESSAGES.map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`h-1.5 w-1.5 rounded-full transition-all duration-300 ${
|
||||
index === messageIndex ? 'w-4 bg-primary' : 'bg-muted-foreground/30'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state === 'REVIEW' && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Detected fields</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-h-[400px] overflow-y-auto">
|
||||
{detectedFields.length === 0 ? (
|
||||
<div className="flex flex-col items-center py-8">
|
||||
<FormInputIcon className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-4 text-center text-sm text-muted-foreground">
|
||||
<Trans>No fields were detected in your document.</Trans>
|
||||
</p>
|
||||
<p className="mt-1 text-center text-xs text-muted-foreground/70">
|
||||
<Trans>You can add fields manually in the editor.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>We found {detectedFields.length} field(s) in your document.</Trans>
|
||||
</p>
|
||||
|
||||
<ul className="mt-4 divide-y rounded-lg border">
|
||||
{fieldCountsByType.map(([type, count]) => (
|
||||
<li key={type} className="flex items-center justify-between px-4 py-3">
|
||||
<span className="text-sm">{_(FIELD_TYPE_LABELS[type]) || type}</span>
|
||||
<span className="text-sm font-medium text-muted-foreground">{count}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
{detectedFields.length > 0 && (
|
||||
<Button type="button" onClick={onAddFields}>
|
||||
<CheckIcon className="-ml-1 mr-2 h-4 w-4" />
|
||||
<Trans>Add fields</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state === 'ERROR' && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Detection failed</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>Something went wrong while detecting fields.</Trans>
|
||||
</p>
|
||||
|
||||
{error && <p className="mt-2 text-sm text-destructive">{error}</p>}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
<Trans>Close</Trans>
|
||||
</Button>
|
||||
<Button type="button" onClick={onDetectClick}>
|
||||
<Trans>Try again</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state === 'RATE_LIMITED' && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Too many requests</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>
|
||||
You've made too many detection requests. Please wait a minute before trying again.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
<Trans>Close</Trans>
|
||||
</Button>
|
||||
<Button type="button" onClick={onDetectClick}>
|
||||
<Trans>Try again</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,361 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { CheckIcon, ShieldCheckIcon, UserIcon, XIcon } from 'lucide-react';
|
||||
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import type { TDetectedRecipientSchema } from '@documenso/lib/server-only/ai/envelope/detect-recipients/schema';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
|
||||
import {
|
||||
AiApiError,
|
||||
type DetectRecipientsProgressEvent,
|
||||
detectRecipients,
|
||||
} from '../../../server/api/ai/detect-recipients.client';
|
||||
import { AnimatedDocumentScanner } from '../general/animated-document-scanner';
|
||||
|
||||
type DialogState = 'PROMPT' | 'PROCESSING' | 'REVIEW' | 'ERROR' | 'RATE_LIMITED';
|
||||
|
||||
type AiRecipientDetectionDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onComplete: (recipients: TDetectedRecipientSchema[]) => void;
|
||||
envelopeId: string;
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
const PROCESSING_MESSAGES = [
|
||||
msg`Reading your document`,
|
||||
msg`Analyzing pages`,
|
||||
msg`Looking for signature fields`,
|
||||
msg`Identifying recipients`,
|
||||
msg`Extracting contact details`,
|
||||
msg`Almost done`,
|
||||
] as const;
|
||||
|
||||
export const AiRecipientDetectionDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
onComplete,
|
||||
envelopeId,
|
||||
teamId,
|
||||
}: AiRecipientDetectionDialogProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const [state, setState] = useState<DialogState>('PROMPT');
|
||||
const [messageIndex, setMessageIndex] = useState(0);
|
||||
const [detectedRecipients, setDetectedRecipients] = useState<TDetectedRecipientSchema[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [progress, setProgress] = useState<DetectRecipientsProgressEvent | null>(null);
|
||||
|
||||
const onDetectClick = useCallback(async () => {
|
||||
setState('PROCESSING');
|
||||
setMessageIndex(0);
|
||||
setError(null);
|
||||
setProgress(null);
|
||||
|
||||
try {
|
||||
await detectRecipients({
|
||||
request: {
|
||||
envelopeId,
|
||||
teamId,
|
||||
},
|
||||
onProgress: (progressEvent) => {
|
||||
setProgress(progressEvent);
|
||||
},
|
||||
onComplete: (event) => {
|
||||
setDetectedRecipients(event.recipients);
|
||||
setState('REVIEW');
|
||||
},
|
||||
onError: (err) => {
|
||||
console.error('Detection failed:', err);
|
||||
|
||||
if (err.status === 429) {
|
||||
setState('RATE_LIMITED');
|
||||
return;
|
||||
}
|
||||
|
||||
setError(err.message);
|
||||
setState('ERROR');
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Detection failed:', err);
|
||||
|
||||
if (err instanceof AiApiError && err.status === 429) {
|
||||
setState('RATE_LIMITED');
|
||||
return;
|
||||
}
|
||||
|
||||
setError(err instanceof Error ? err.message : 'Failed to detect recipients');
|
||||
setState('ERROR');
|
||||
}
|
||||
}, [envelopeId, teamId]);
|
||||
|
||||
const handleRemoveRecipient = (index: number) => {
|
||||
setDetectedRecipients((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const onAddRecipients = () => {
|
||||
onComplete(detectedRecipients);
|
||||
onOpenChange(false);
|
||||
setState('PROMPT');
|
||||
setDetectedRecipients([]);
|
||||
};
|
||||
|
||||
const onClose = () => {
|
||||
onOpenChange(false);
|
||||
setState('PROMPT');
|
||||
setDetectedRecipients([]);
|
||||
setError(null);
|
||||
setProgress(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (state !== 'PROCESSING') {
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setMessageIndex((prev) => (prev + 1) % PROCESSING_MESSAGES.length);
|
||||
}, 4000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [state]);
|
||||
|
||||
return (
|
||||
<Dialog open={open}>
|
||||
<DialogContent className="sm:max-w-lg" hideClose={true}>
|
||||
{state === 'PROMPT' && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Detect recipients</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>
|
||||
We'll scan your document to find signature fields and identify who needs to sign.
|
||||
Detected recipients will be suggested for you to review.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<Alert className="mt-4 flex items-center gap-2 space-y-0" variant="neutral">
|
||||
<ShieldCheckIcon className="h-5 w-5 stroke-green-600" />
|
||||
<AlertDescription className="mt-0">
|
||||
<Trans>
|
||||
Your document is processed securely using AI services that don't retain your
|
||||
data.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
<Trans>Skip</Trans>
|
||||
</Button>
|
||||
<Button type="button" onClick={onDetectClick}>
|
||||
<Trans>Detect</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state === 'PROCESSING' && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Detecting recipients</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col items-center py-8">
|
||||
<AnimatedDocumentScanner />
|
||||
|
||||
<p className="mt-8 text-muted-foreground">{_(PROCESSING_MESSAGES[messageIndex])}</p>
|
||||
|
||||
{progress && (
|
||||
<p className="mt-2 text-xs text-muted-foreground/60">
|
||||
<Trans>
|
||||
Page {progress.pagesProcessed} of {progress.totalPages} -{' '}
|
||||
{progress.recipientsDetected} recipient(s) found
|
||||
</Trans>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="mt-2 max-w-[40ch] text-center text-xs text-muted-foreground/60">
|
||||
<Trans>This can take a minute or two depending on the size of your document.</Trans>
|
||||
</p>
|
||||
|
||||
<div className="mt-4 flex gap-1">
|
||||
{PROCESSING_MESSAGES.map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`h-1.5 w-1.5 rounded-full transition-all duration-300 ${
|
||||
index === messageIndex ? 'w-4 bg-primary' : 'bg-muted-foreground/30'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state === 'REVIEW' && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Detected recipients</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-h-[400px] overflow-y-auto">
|
||||
{detectedRecipients.length === 0 ? (
|
||||
<div className="flex flex-col items-center py-8">
|
||||
<UserIcon className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-4 text-center text-sm text-muted-foreground">
|
||||
<Trans>No recipients were detected in your document.</Trans>
|
||||
</p>
|
||||
<p className="mt-1 text-center text-xs text-muted-foreground/70">
|
||||
<Trans>You can add recipients manually in the editor.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>
|
||||
We found {detectedRecipients.length} recipient(s) in your document.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<ul className="mt-4 divide-y rounded-lg border">
|
||||
{detectedRecipients.map((recipient, index) => (
|
||||
<li key={index} className="flex items-center justify-between px-4 py-3">
|
||||
<AvatarWithText
|
||||
avatarFallback={
|
||||
recipient.name
|
||||
? recipient.name.slice(0, 1).toUpperCase()
|
||||
: recipient.email
|
||||
? recipient.email.slice(0, 1).toUpperCase()
|
||||
: '?'
|
||||
}
|
||||
primaryText={
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{recipient.name || _(msg`Unknown name`)}
|
||||
</p>
|
||||
}
|
||||
secondaryText={
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<p className="italic text-muted-foreground/70">
|
||||
{recipient.email || _(msg`No email detected`)}
|
||||
</p>
|
||||
<p>{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}</p>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="h-8 w-8 p-0 text-muted-foreground/80 hover:text-destructive focus-visible:border-destructive focus-visible:ring-destructive"
|
||||
onClick={() => handleRemoveRecipient(index)}
|
||||
>
|
||||
<span className="sr-only">
|
||||
<Trans>Remove recipient</Trans>
|
||||
</span>
|
||||
|
||||
<XIcon className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
{detectedRecipients.length > 0 && (
|
||||
<Button type="button" onClick={onAddRecipients}>
|
||||
<CheckIcon className="-ml-1 mr-2 h-4 w-4" />
|
||||
<Trans>Add recipients</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state === 'ERROR' && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Detection failed</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>Something went wrong while detecting recipients.</Trans>
|
||||
</p>
|
||||
|
||||
{error && <p className="mt-2 text-sm text-destructive">{error}</p>}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
<Trans>Close</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="button" onClick={onDetectClick}>
|
||||
<Trans>Try again</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state === 'RATE_LIMITED' && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Too many requests</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>
|
||||
You've made too many detection requests. Please wait a minute before trying again.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
<Trans>Close</Trans>
|
||||
</Button>
|
||||
<Button type="button" onClick={onDetectClick}>
|
||||
<Trans>Try again</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
@@ -7,9 +7,7 @@ import {
|
||||
DocumentDistributionMethod,
|
||||
DocumentStatus,
|
||||
EnvelopeType,
|
||||
type Field,
|
||||
FieldType,
|
||||
type Recipient,
|
||||
RecipientRole,
|
||||
} from '@prisma/client';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
@@ -19,8 +17,8 @@ import { useNavigate } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
import * as z from 'zod';
|
||||
|
||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
||||
import { trpc, trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@@ -52,16 +50,13 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@documenso/ui/primitives/select';
|
||||
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||
import { Textarea } from '@documenso/ui/primitives/textarea';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type EnvelopeDistributeDialogProps = {
|
||||
envelope: Pick<TEnvelope, 'id' | 'userId' | 'teamId' | 'status' | 'type' | 'documentMeta'> & {
|
||||
recipients: Recipient[];
|
||||
fields: Pick<Field, 'type' | 'recipientId'>[];
|
||||
};
|
||||
onDistribute?: () => Promise<void>;
|
||||
documentRootPath: string;
|
||||
trigger?: React.ReactNode;
|
||||
@@ -86,20 +81,20 @@ export const ZEnvelopeDistributeFormSchema = z.object({
|
||||
export type TEnvelopeDistributeFormSchema = z.infer<typeof ZEnvelopeDistributeFormSchema>;
|
||||
|
||||
export const EnvelopeDistributeDialog = ({
|
||||
envelope,
|
||||
trigger,
|
||||
documentRootPath,
|
||||
onDistribute,
|
||||
}: EnvelopeDistributeDialogProps) => {
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const recipients = envelope.recipients;
|
||||
const { envelope, syncEnvelope, isAutosaving, autosaveError } = useCurrentEnvelopeEditor();
|
||||
|
||||
const { toast } = useToast();
|
||||
const { t } = useLingui();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
|
||||
const { mutateAsync: distributeEnvelope } = trpcReact.envelope.distribute.useMutation();
|
||||
|
||||
@@ -189,6 +184,29 @@ export const EnvelopeDistributeDialog = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleSync = async () => {
|
||||
if (isSyncing) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSyncing(true);
|
||||
|
||||
try {
|
||||
await syncEnvelope();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
|
||||
setIsSyncing(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Resync the whole envelope if the envelope is mid saving.
|
||||
if (isOpen && (isAutosaving || autosaveError)) {
|
||||
void handleSync();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
if (envelope.status !== DocumentStatus.DRAFT || envelope.type !== EnvelopeType.DOCUMENT) {
|
||||
return null;
|
||||
}
|
||||
@@ -208,7 +226,7 @@ export const EnvelopeDistributeDialog = ({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{!invalidEnvelopeCode ? (
|
||||
{!invalidEnvelopeCode || isSyncing ? (
|
||||
<Form {...form}>
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
<fieldset disabled={isSubmitting}>
|
||||
@@ -236,7 +254,16 @@ export const EnvelopeDistributeDialog = ({
|
||||
})}
|
||||
>
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
{distributionMethod === DocumentDistributionMethod.EMAIL && (
|
||||
{isSyncing ? (
|
||||
<motion.div
|
||||
key={'Flushing'}
|
||||
initial={{ opacity: 0, y: 5 }}
|
||||
animate={{ opacity: 1, y: 0, transition: { duration: 0.3 } }}
|
||||
exit={{ opacity: 0, transition: { duration: 0.15 } }}
|
||||
>
|
||||
<SpinnerBox spinnerProps={{ size: 'sm' }} className="h-72" />
|
||||
</motion.div>
|
||||
) : distributionMethod === DocumentDistributionMethod.EMAIL ? (
|
||||
<motion.div
|
||||
key={'Emails'}
|
||||
initial={{ opacity: 0, y: 5 }}
|
||||
@@ -339,7 +366,7 @@ export const EnvelopeDistributeDialog = ({
|
||||
<TooltipTrigger type="button">
|
||||
<InfoIcon className="mx-2 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="text-muted-foreground p-4">
|
||||
<TooltipContent className="p-4 text-muted-foreground">
|
||||
<DocumentSendEmailMessageHelper />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
@@ -347,7 +374,7 @@ export const EnvelopeDistributeDialog = ({
|
||||
|
||||
<FormControl>
|
||||
<Textarea
|
||||
className="bg-background mt-2 h-16 resize-none"
|
||||
className="mt-2 h-16 resize-none bg-background"
|
||||
{...field}
|
||||
maxLength={5000}
|
||||
/>
|
||||
@@ -359,9 +386,7 @@ export const EnvelopeDistributeDialog = ({
|
||||
</fieldset>
|
||||
</Form>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{distributionMethod === DocumentDistributionMethod.NONE && (
|
||||
) : distributionMethod === DocumentDistributionMethod.NONE ? (
|
||||
<motion.div
|
||||
key={'Links'}
|
||||
initial={{ opacity: 0, y: 5 }}
|
||||
@@ -369,7 +394,7 @@ export const EnvelopeDistributeDialog = ({
|
||||
exit={{ opacity: 0, transition: { duration: 0.15 } }}
|
||||
className="min-h-60 rounded-lg border"
|
||||
>
|
||||
<div className="text-muted-foreground py-24 text-center text-sm">
|
||||
<div className="py-24 text-center text-sm text-muted-foreground">
|
||||
<p>
|
||||
<Trans>We won't send anything to notify recipients.</Trans>
|
||||
</p>
|
||||
@@ -382,7 +407,7 @@ export const EnvelopeDistributeDialog = ({
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
@@ -393,7 +418,7 @@ export const EnvelopeDistributeDialog = ({
|
||||
</Button>
|
||||
</DialogClose>
|
||||
|
||||
<Button loading={isSubmitting} type="submit">
|
||||
<Button loading={isSubmitting} disabled={isSyncing} type="submit">
|
||||
{distributionMethod === DocumentDistributionMethod.EMAIL ? (
|
||||
<Trans>Send</Trans>
|
||||
) : (
|
||||
|
||||
@@ -367,7 +367,7 @@ const BillingPlanForm = ({
|
||||
<div className="w-full text-left">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-medium">
|
||||
<Trans>Free</Trans>
|
||||
<Trans context="Plan price">Free</Trans>
|
||||
</p>
|
||||
|
||||
<Badge size="small" variant="neutral" className="ml-1.5">
|
||||
|
||||
@@ -72,6 +72,7 @@ export const OrganisationEmailCreateDialog = ({
|
||||
const { mutateAsync: createOrganisationEmail, isPending } =
|
||||
trpc.enterprise.organisation.email.create.useMutation();
|
||||
|
||||
// Reset state when dialog closes
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
form.reset();
|
||||
|
||||
@@ -1,251 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,408 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -103,8 +103,8 @@ export const TemplateBulkSendDialog = ({
|
||||
console.error(err);
|
||||
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to upload CSV. Please check the file format and try again.',
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`Failed to upload CSV. Please check the file format and try again.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ 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 {
|
||||
@@ -96,15 +97,18 @@ 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],
|
||||
@@ -114,14 +118,17 @@ 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],
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { EnvelopeItem, FieldType } from '@prisma/client';
|
||||
@@ -229,8 +230,8 @@ export const ConfigureFieldsView = ({
|
||||
setFieldClipboard(lastActiveField);
|
||||
|
||||
toast({
|
||||
title: 'Copied field',
|
||||
description: 'Copied field to clipboard',
|
||||
title: _(msg`Copied field`),
|
||||
description: _(msg`Copied field to clipboard`),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -150,8 +150,8 @@ export const MultiSignDocumentSigningView = ({
|
||||
onDocumentError?.();
|
||||
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to complete the document. Please try again.',
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`Failed to complete the document. Please try again.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
|
||||
@@ -58,6 +58,7 @@ export type TDocumentPreferencesFormSchema = {
|
||||
includeSigningCertificate: boolean | null;
|
||||
includeAuditLog: boolean | null;
|
||||
signatureTypes: DocumentSignatureType[];
|
||||
aiFeaturesEnabled: boolean | null;
|
||||
};
|
||||
|
||||
type SettingsSubset = Pick<
|
||||
@@ -72,11 +73,13 @@ type SettingsSubset = Pick<
|
||||
| 'typedSignatureEnabled'
|
||||
| 'uploadSignatureEnabled'
|
||||
| 'drawSignatureEnabled'
|
||||
| 'aiFeaturesEnabled'
|
||||
>;
|
||||
|
||||
export type DocumentPreferencesFormProps = {
|
||||
settings: SettingsSubset;
|
||||
canInherit: boolean;
|
||||
isAiFeaturesConfigured?: boolean;
|
||||
onFormSubmit: (data: TDocumentPreferencesFormSchema) => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -84,6 +87,7 @@ export const DocumentPreferencesForm = ({
|
||||
settings,
|
||||
onFormSubmit,
|
||||
canInherit,
|
||||
isAiFeaturesConfigured = false,
|
||||
}: DocumentPreferencesFormProps) => {
|
||||
const { t } = useLingui();
|
||||
const { user, organisations } = useSession();
|
||||
@@ -105,6 +109,7 @@ export const DocumentPreferencesForm = ({
|
||||
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(canInherit ? 0 : 1, {
|
||||
message: msg`At least one signature type must be enabled`.id,
|
||||
}),
|
||||
aiFeaturesEnabled: z.boolean().nullable(),
|
||||
});
|
||||
|
||||
const form = useForm<TDocumentPreferencesFormSchema>({
|
||||
@@ -120,6 +125,7 @@ export const DocumentPreferencesForm = ({
|
||||
includeSigningCertificate: settings.includeSigningCertificate,
|
||||
includeAuditLog: settings.includeAuditLog,
|
||||
signatureTypes: extractTeamSignatureSettings({ ...settings }),
|
||||
aiFeaturesEnabled: settings.aiFeaturesEnabled,
|
||||
},
|
||||
resolver: zodResolver(ZDocumentPreferencesFormSchema),
|
||||
});
|
||||
@@ -312,7 +318,7 @@ export const DocumentPreferencesForm = ({
|
||||
}))}
|
||||
selectedValues={field.value}
|
||||
onChange={field.onChange}
|
||||
className="bg-background w-full"
|
||||
className="w-full bg-background"
|
||||
enableSearch={false}
|
||||
emptySelectionPlaceholder={
|
||||
canInherit ? t`Inherit from organisation` : t`Select signature types`
|
||||
@@ -378,7 +384,7 @@ export const DocumentPreferencesForm = ({
|
||||
</FormControl>
|
||||
|
||||
<div className="pt-2">
|
||||
<div className="text-muted-foreground text-xs font-medium">
|
||||
<div className="text-xs font-medium text-muted-foreground">
|
||||
<Trans>Preview</Trans>
|
||||
</div>
|
||||
|
||||
@@ -509,6 +515,59 @@ export const DocumentPreferencesForm = ({
|
||||
)}
|
||||
/>
|
||||
|
||||
{isAiFeaturesConfigured && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="aiFeaturesEnabled"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>AI Features</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Select
|
||||
{...field}
|
||||
value={field.value === null ? '-1' : field.value.toString()}
|
||||
onValueChange={(value) =>
|
||||
field.onChange(value === 'true' ? true : value === 'false' ? false : null)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="bg-background text-muted-foreground">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectItem value="true">
|
||||
<Trans>Enabled</Trans>
|
||||
</SelectItem>
|
||||
|
||||
<SelectItem value="false">
|
||||
<Trans>Disabled</Trans>
|
||||
</SelectItem>
|
||||
|
||||
{canInherit && (
|
||||
<SelectItem value={'-1'}>
|
||||
<Trans>Inherit from organisation</Trans>
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>
|
||||
Enable AI-powered features such as automatic recipient detection. When
|
||||
enabled, document content will be sent to AI providers. We only use providers
|
||||
that do not retain data for training and prefer European regions where
|
||||
available.
|
||||
</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex flex-row justify-end space-x-4">
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
<Trans>Update</Trans>
|
||||
|
||||
@@ -201,7 +201,7 @@ export const SignInForm = ({
|
||||
.otherwise(() => handleFallbackErrorMessages(error.code));
|
||||
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
title: _(msg`Something went wrong`),
|
||||
description: _(errorMessage),
|
||||
duration: 10000,
|
||||
variant: 'destructive',
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { SearchIcon } from 'lucide-react';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
||||
export type AnimatedDocumentScannerProps = {
|
||||
className?: string;
|
||||
interval?: number;
|
||||
};
|
||||
|
||||
export const AnimatedDocumentScanner = ({
|
||||
className,
|
||||
interval = 2500,
|
||||
}: AnimatedDocumentScannerProps) => {
|
||||
const [magPosition, setMagPosition] = useState({ x: 0, y: 0, page: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
const moveInterval = setInterval(() => {
|
||||
setMagPosition({
|
||||
x: Math.random() * 60 - 30,
|
||||
y: Math.random() * 50 - 25,
|
||||
page: Math.random() > 0.5 ? 1 : 0,
|
||||
});
|
||||
}, interval);
|
||||
|
||||
return () => clearInterval(moveInterval);
|
||||
}, [interval]);
|
||||
|
||||
return (
|
||||
<div className={cn('relative mx-auto h-36 w-56', className)}>
|
||||
{/* Magnifying glass */}
|
||||
<div
|
||||
className="pointer-events-none absolute z-50 transition-all duration-1000 ease-in-out"
|
||||
style={{
|
||||
left: magPosition.page === 0 ? '25%' : '75%',
|
||||
top: '50%',
|
||||
transform: `translate(calc(-50% + ${magPosition.x}px), calc(-50% + ${magPosition.y}px))`,
|
||||
}}
|
||||
>
|
||||
<SearchIcon className="h-8 w-8 text-foreground" />
|
||||
</div>
|
||||
|
||||
{/* Book container */}
|
||||
<div className="relative h-full w-full animate-pulse" style={{ perspective: '800px' }}>
|
||||
<div className="relative h-full w-full" style={{ transformStyle: 'preserve-3d' }}>
|
||||
{/* Left page */}
|
||||
<div
|
||||
className="absolute left-0 top-0 h-full w-[calc(50%)] origin-right overflow-hidden rounded-l-md border border-border bg-card shadow-md"
|
||||
style={{ transform: 'rotateY(15deg) skewY(-1deg)' }}
|
||||
>
|
||||
<div className="absolute inset-3 space-y-2">
|
||||
<div className="h-1.5 w-3/4 rounded-sm bg-muted" />
|
||||
<div className="h-1.5 w-full rounded-sm bg-muted" />
|
||||
<div className="h-1.5 w-5/6 rounded-sm bg-muted" />
|
||||
<div className="h-1.5 w-2/3 rounded-sm bg-muted" />
|
||||
<div className="mt-3 h-6 w-3/4 rounded border border-dashed border-primary" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right page */}
|
||||
<div
|
||||
className="absolute right-0 top-0 h-full w-[calc(50%)] origin-left overflow-hidden rounded-r-md border border-border bg-card shadow-md"
|
||||
style={{ transform: 'rotateY(-15deg) skewY(1deg)' }}
|
||||
>
|
||||
<div className="absolute inset-3 space-y-2">
|
||||
<div className="h-1.5 w-full rounded-sm bg-muted" />
|
||||
<div className="h-1.5 w-4/5 rounded-sm bg-muted" />
|
||||
<div className="h-1.5 w-full rounded-sm bg-muted" />
|
||||
<div className="h-1.5 w-3/5 rounded-sm bg-muted" />
|
||||
<div className="mt-3 h-6 w-2/3 rounded border border-dashed border-primary" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -108,8 +108,8 @@ export const DocumentSigningForm = ({
|
||||
await completeDocument({ nextSigner });
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'An error occurred while completing the document. Please try again.',
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while completing the document. Please try again.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
|
||||
+36
-7
@@ -187,45 +187,74 @@ export const DocumentSigningPageViewV1 = ({
|
||||
|
||||
<div className="mt-1.5 flex flex-wrap items-center justify-between gap-y-2 sm:mt-2.5 sm:gap-y-0">
|
||||
<div className="max-w-[50ch]">
|
||||
<span className="truncate text-muted-foreground" title={senderName}>
|
||||
{senderName} {senderEmail}
|
||||
</span>{' '}
|
||||
<span className="text-muted-foreground">
|
||||
{match(recipient.role)
|
||||
.with(RecipientRole.VIEWER, () =>
|
||||
includeSenderDetails ? (
|
||||
<Trans>
|
||||
<span className="truncate" title={senderName}>
|
||||
{senderName} {senderEmail}
|
||||
</span>{' '}
|
||||
on behalf of "{document.team?.name}" has invited you to view this document
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>has invited you to view this document</Trans>
|
||||
<Trans>
|
||||
<span className="truncate" title={senderName}>
|
||||
{senderName} {senderEmail}
|
||||
</span>{' '}
|
||||
has invited you to view this document
|
||||
</Trans>
|
||||
),
|
||||
)
|
||||
.with(RecipientRole.SIGNER, () =>
|
||||
includeSenderDetails ? (
|
||||
<Trans>
|
||||
<span className="truncate" title={senderName}>
|
||||
{senderName} {senderEmail}
|
||||
</span>{' '}
|
||||
on behalf of "{document.team?.name}" has invited you to sign this document
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>has invited you to sign this document</Trans>
|
||||
<Trans>
|
||||
<span className="truncate" title={senderName}>
|
||||
{senderName} {senderEmail}
|
||||
</span>{' '}
|
||||
has invited you to sign this document
|
||||
</Trans>
|
||||
),
|
||||
)
|
||||
.with(RecipientRole.APPROVER, () =>
|
||||
includeSenderDetails ? (
|
||||
<Trans>
|
||||
<span className="truncate" title={senderName}>
|
||||
{senderName} {senderEmail}
|
||||
</span>{' '}
|
||||
on behalf of "{document.team?.name}" has invited you to approve this document
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>has invited you to approve this document</Trans>
|
||||
<Trans>
|
||||
<span className="truncate" title={senderName}>
|
||||
{senderName} {senderEmail}
|
||||
</span>{' '}
|
||||
has invited you to approve this document
|
||||
</Trans>
|
||||
),
|
||||
)
|
||||
.with(RecipientRole.ASSISTANT, () =>
|
||||
includeSenderDetails ? (
|
||||
<Trans>
|
||||
<span className="truncate" title={senderName}>
|
||||
{senderName} {senderEmail}
|
||||
</span>{' '}
|
||||
on behalf of "{document.team?.name}" has invited you to assist this document
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>has invited you to assist this document</Trans>
|
||||
<Trans>
|
||||
<span className="truncate" title={senderName}>
|
||||
{senderName} {senderEmail}
|
||||
</span>{' '}
|
||||
has invited you to assist this document
|
||||
</Trans>
|
||||
),
|
||||
)
|
||||
.otherwise(() => null)}
|
||||
|
||||
+4
-4
@@ -74,8 +74,8 @@ export function DocumentSigningRejectDialog({
|
||||
});
|
||||
|
||||
toast({
|
||||
title: 'Document rejected',
|
||||
description: 'The document has been successfully rejected.',
|
||||
title: t`Document rejected`,
|
||||
description: t`The document has been successfully rejected.`,
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
@@ -88,8 +88,8 @@ export function DocumentSigningRejectDialog({
|
||||
}
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'An error occurred while rejecting the document. Please try again.',
|
||||
title: t`Error`,
|
||||
description: t`An error occurred while rejecting the document. Please try again.`,
|
||||
variant: 'destructive',
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
+21
-1
@@ -174,12 +174,18 @@ 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();
|
||||
|
||||
@@ -264,6 +270,7 @@ 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;
|
||||
}
|
||||
@@ -289,6 +296,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
});
|
||||
|
||||
currentStage.on('mousemove touchmove', () => {
|
||||
// do nothing if we didn't start selection
|
||||
if (!selectionRectangle.visible()) {
|
||||
return;
|
||||
}
|
||||
@@ -313,6 +321,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
});
|
||||
|
||||
currentStage.on('mouseup touchend', () => {
|
||||
// do nothing if we didn't start selection
|
||||
if (!selectionRectangle.visible()) {
|
||||
return;
|
||||
}
|
||||
@@ -366,25 +375,34 @@ 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) {
|
||||
const nodes = transformer.nodes().slice();
|
||||
// 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
|
||||
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);
|
||||
}
|
||||
@@ -411,10 +429,12 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
||||
}
|
||||
});
|
||||
|
||||
// If it exists, rerender.
|
||||
localPageFields.forEach((field) => {
|
||||
renderFieldOnLayer(field);
|
||||
});
|
||||
|
||||
// Rerender the transformer
|
||||
interactiveTransformer.current?.forceUpdate();
|
||||
|
||||
pageLayer.current.batchDraw();
|
||||
|
||||
+78
-312
@@ -1,39 +1,40 @@
|
||||
import { lazy, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
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';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
|
||||
import { FileTextIcon, SparklesIcon } from 'lucide-react';
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
import { isDeepEqual } from 'remeda';
|
||||
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,
|
||||
TDropdownFieldMeta,
|
||||
TEmailFieldMeta,
|
||||
TFieldMetaSchema,
|
||||
TInitialsFieldMeta,
|
||||
TNameFieldMeta,
|
||||
TNumberFieldMeta,
|
||||
TRadioFieldMeta,
|
||||
TSignatureFieldMeta,
|
||||
TTextFieldMeta,
|
||||
import type { NormalizedFieldWithContext } from '@documenso/lib/server-only/ai/envelope/detect-fields/types';
|
||||
import {
|
||||
FIELD_META_DEFAULT_VALUES,
|
||||
type TCheckboxFieldMeta,
|
||||
type TDateFieldMeta,
|
||||
type TDropdownFieldMeta,
|
||||
type TEmailFieldMeta,
|
||||
type TFieldMetaSchema,
|
||||
type TInitialsFieldMeta,
|
||||
type TNameFieldMeta,
|
||||
type TNumberFieldMeta,
|
||||
type TRadioFieldMeta,
|
||||
type TSignatureFieldMeta,
|
||||
type 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 { AiFieldDetectionDialog } from '~/components/dialogs/ai-field-detection-dialog';
|
||||
import { EditorFieldCheckboxForm } from '~/components/forms/editor/editor-field-checkbox-form';
|
||||
import { EditorFieldDateForm } from '~/components/forms/editor/editor-field-date-form';
|
||||
import { EditorFieldDropdownForm } from '~/components/forms/editor/editor-field-dropdown-form';
|
||||
@@ -44,6 +45,7 @@ import { EditorFieldNumberForm } from '~/components/forms/editor/editor-field-nu
|
||||
import { EditorFieldRadioForm } from '~/components/forms/editor/editor-field-radio-form';
|
||||
import { EditorFieldSignatureForm } from '~/components/forms/editor/editor-field-signature-form';
|
||||
import { EditorFieldTextForm } from '~/components/forms/editor/editor-field-text-form';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
import { EnvelopeEditorFieldDragDrop } from './envelope-editor-fields-drag-drop';
|
||||
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
|
||||
@@ -53,51 +55,6 @@ const EnvelopeEditorFieldsPageRenderer = lazy(
|
||||
async () => import('~/components/general/envelope-editor/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`,
|
||||
@@ -115,19 +72,15 @@ const FieldSettingsTypeTranslations: Record<FieldType, MessageDescriptor> = {
|
||||
export const EnvelopeEditorFieldsPage = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const { envelope, editorFields, relativePath } = useCurrentEnvelopeEditor();
|
||||
|
||||
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { _ } = useLingui();
|
||||
|
||||
const [isDetectingFields, setIsAutoAddingFields] = useState(false);
|
||||
const [processingProgress, setProcessingProgress] = useState<{
|
||||
current: number;
|
||||
total: number;
|
||||
} | null>(null);
|
||||
const [hasAutoPlacedFields, setHasAutoPlacedFields] = useState(false);
|
||||
const [isAiFieldDialogOpen, setIsAiFieldDialogOpen] = useState(false);
|
||||
|
||||
const selectedField = useMemo(
|
||||
() => structuredClone(editorFields.selectedField),
|
||||
@@ -141,13 +94,38 @@ 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');
|
||||
}
|
||||
};
|
||||
|
||||
const onFieldDetectionComplete = (fields: NormalizedFieldWithContext[]) => {
|
||||
for (const field of fields) {
|
||||
editorFields.addField({
|
||||
height: field.height,
|
||||
width: field.width,
|
||||
positionX: field.positionX,
|
||||
positionY: field.positionY,
|
||||
type: field.type,
|
||||
envelopeItemId: field.envelopeItemId,
|
||||
recipientId: field.recipientId,
|
||||
page: field.pageNumber,
|
||||
fieldMeta: structuredClone(FIELD_META_DEFAULT_VALUES[field.type]),
|
||||
});
|
||||
}
|
||||
|
||||
setIsAiFieldDialogOpen(false);
|
||||
};
|
||||
|
||||
/**
|
||||
* Set the selected recipient to the first recipient in the envelope.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const firstSelectableRecipient = envelope.recipients.find(
|
||||
(recipient) =>
|
||||
@@ -157,128 +135,10 @@ 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="relative flex w-full flex-col overflow-y-auto">
|
||||
<div className="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 */}
|
||||
@@ -370,128 +230,34 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
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;
|
||||
{team.preferences.aiFeaturesEnabled && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-4 w-full"
|
||||
onClick={() => setIsAiFieldDialogOpen(true)}
|
||||
disabled={envelope.status !== DocumentStatus.DRAFT}
|
||||
title={
|
||||
envelope.status !== DocumentStatus.DRAFT
|
||||
? _(msg`You can only detect fields in draft envelopes`)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<SparklesIcon className="-ml-1 mr-2 h-4 w-4" />
|
||||
<Trans>Detect with AI</Trans>
|
||||
</Button>
|
||||
|
||||
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>
|
||||
<AiFieldDetectionDialog
|
||||
open={isAiFieldDialogOpen}
|
||||
onOpenChange={setIsAiFieldDialogOpen}
|
||||
onComplete={onFieldDetectionComplete}
|
||||
envelopeId={envelope.id}
|
||||
teamId={envelope.teamId}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Field details section. */}
|
||||
@@ -533,7 +299,7 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
|
||||
<div className="px-4 [&_label]:text-xs [&_label]:text-foreground/70">
|
||||
<h3 className="text-sm font-semibold">
|
||||
{t(FieldSettingsTypeTranslations[selectedField.type])}
|
||||
{_(FieldSettingsTypeTranslations[selectedField.type])}
|
||||
</h3>
|
||||
|
||||
{match(selectedField.type)
|
||||
|
||||
@@ -30,18 +30,11 @@ import { EnvelopeItemTitleInput } from './envelope-editor-title-input';
|
||||
export default function EnvelopeEditorHeader() {
|
||||
const { t } = useLingui();
|
||||
|
||||
const {
|
||||
envelope,
|
||||
isDocument,
|
||||
isTemplate,
|
||||
updateEnvelope,
|
||||
autosaveError,
|
||||
relativePath,
|
||||
editorFields,
|
||||
} = useCurrentEnvelopeEditor();
|
||||
const { envelope, isDocument, isTemplate, updateEnvelope, autosaveError, relativePath } =
|
||||
useCurrentEnvelopeEditor();
|
||||
|
||||
return (
|
||||
<nav className="bg-background border-border w-full border-b px-4 py-3 md:px-6">
|
||||
<nav className="w-full border-b border-border bg-background px-4 py-3 md:px-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Link to="/">
|
||||
@@ -147,10 +140,6 @@ export default function EnvelopeEditorHeader() {
|
||||
{isDocument && (
|
||||
<>
|
||||
<EnvelopeDistributeDialog
|
||||
envelope={{
|
||||
...envelope,
|
||||
fields: editorFields.localFields,
|
||||
}}
|
||||
documentRootPath={relativePath.documentRootPath}
|
||||
trigger={
|
||||
<Button size="sm">
|
||||
|
||||
+134
-8
@@ -12,8 +12,9 @@ import { msg } from '@lingui/core/macro';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { DocumentSigningOrder, EnvelopeType, RecipientRole, SendStatus } from '@prisma/client';
|
||||
import { motion } from 'framer-motion';
|
||||
import { GripVerticalIcon, HelpCircleIcon, PlusIcon, TrashIcon } from 'lucide-react';
|
||||
import { GripVerticalIcon, HelpCircleIcon, PlusIcon, SparklesIcon, TrashIcon } from 'lucide-react';
|
||||
import { useFieldArray, useForm, useWatch } from 'react-hook-form';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { isDeepEqual, prop, sortBy } from 'remeda';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -22,6 +23,7 @@ import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounce
|
||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import type { TDetectedRecipientSchema } from '@documenso/lib/server-only/ai/envelope/detect-recipients/schema';
|
||||
import {
|
||||
ZRecipientActionAuthTypesSchema,
|
||||
ZRecipientAuthOptionsSchema,
|
||||
@@ -60,6 +62,9 @@ import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { AiRecipientDetectionDialog } from '~/components/dialogs/ai-recipient-detection-dialog';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
const ZEnvelopeRecipientsForm = z.object({
|
||||
signers: z.array(
|
||||
z.object({
|
||||
@@ -85,14 +90,36 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
const { envelope, setRecipientsDebounced, updateEnvelope } = useCurrentEnvelopeEditor();
|
||||
|
||||
const organisation = useCurrentOrganisation();
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { remaining } = useLimits();
|
||||
const { user } = useSession();
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [recipientSearchQuery, setRecipientSearchQuery] = useState('');
|
||||
|
||||
// AI recipient detection dialog state
|
||||
const [isAiDialogOpen, setIsAiDialogOpen] = useState(() => searchParams.get('ai') === 'true');
|
||||
|
||||
const onAiDialogOpenChange = (open: boolean) => {
|
||||
setIsAiDialogOpen(open);
|
||||
|
||||
if (!open && searchParams.get('ai') === 'true') {
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const newParams = new URLSearchParams(prev);
|
||||
|
||||
newParams.delete('ai');
|
||||
|
||||
return newParams;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const debouncedRecipientSearchQuery = useDebouncedValue(recipientSearchQuery, 500);
|
||||
|
||||
const initialId = useId();
|
||||
@@ -244,6 +271,71 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const onAiDetectionComplete = (detectedRecipients: TDetectedRecipientSchema[]) => {
|
||||
const currentSigners = form.getValues('signers');
|
||||
|
||||
let nextSigningOrder =
|
||||
currentSigners.length > 0
|
||||
? Math.max(...currentSigners.map((s) => s.signingOrder ?? 0)) + 1
|
||||
: 1;
|
||||
|
||||
// If the only signer is the default empty signer lets just replace it with the detected recipients
|
||||
if (currentSigners.length === 1 && !currentSigners[0].name && !currentSigners[0].email) {
|
||||
form.setValue(
|
||||
'signers',
|
||||
detectedRecipients.map((recipient, index) => ({
|
||||
formId: nanoid(12),
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
role: recipient.role,
|
||||
actionAuth: [],
|
||||
signingOrder: index + 1,
|
||||
})),
|
||||
{
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
},
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
for (const recipient of detectedRecipients) {
|
||||
const emailExists = currentSigners.some(
|
||||
(s) => s.email.toLowerCase() === recipient.email.toLowerCase(),
|
||||
);
|
||||
|
||||
const nameExists = currentSigners.some(
|
||||
(s) => s.name.toLowerCase() === recipient.name.toLowerCase(),
|
||||
);
|
||||
|
||||
if ((emailExists && recipient.email) || (nameExists && recipient.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
currentSigners.push({
|
||||
formId: nanoid(12),
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
role: recipient.role,
|
||||
actionAuth: [],
|
||||
signingOrder: nextSigningOrder,
|
||||
});
|
||||
|
||||
nextSigningOrder += 1;
|
||||
}
|
||||
|
||||
form.setValue('signers', normalizeSigningOrders(currentSigners), {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t`Recipients added`,
|
||||
description: t`${detectedRecipients.length} recipient(s) have been added from AI detection.`,
|
||||
});
|
||||
};
|
||||
|
||||
const onRemoveSigner = (index: number) => {
|
||||
const signer = signers[index];
|
||||
|
||||
@@ -306,8 +398,14 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
index: number,
|
||||
suggestion: RecipientAutoCompleteOption,
|
||||
) => {
|
||||
setValue(`signers.${index}.email`, suggestion.email);
|
||||
setValue(`signers.${index}.name`, suggestion.name || '');
|
||||
setValue(`signers.${index}.email`, suggestion.email, {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
setValue(`signers.${index}.name`, suggestion.name || '', {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
};
|
||||
|
||||
const onDragEnd = useCallback(
|
||||
@@ -543,6 +641,26 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center space-x-2">
|
||||
{team.preferences.aiFeaturesEnabled && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={isSubmitting}
|
||||
onClick={() => setIsAiDialogOpen(true)}
|
||||
>
|
||||
<SparklesIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent>
|
||||
<Trans>Detect recipients with AI</Trans>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex flex-row items-center"
|
||||
@@ -570,7 +688,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
<CardContent>
|
||||
<AnimateGenericFadeInOut motionKey={showAdvancedSettings ? 'Show' : 'Hide'}>
|
||||
<Form {...form}>
|
||||
<div className="bg-accent/50 -mt-2 mb-2 space-y-4 rounded-md p-4">
|
||||
<div className="-mt-2 mb-2 space-y-4 rounded-md bg-accent/50 p-4">
|
||||
{organisation.organisationClaim.flags.cfr21 && (
|
||||
<div className="flex flex-row items-center">
|
||||
<Checkbox
|
||||
@@ -634,7 +752,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-muted-foreground ml-1 cursor-help">
|
||||
<span className="ml-1 cursor-help text-muted-foreground">
|
||||
<HelpCircleIcon className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
@@ -679,7 +797,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-muted-foreground ml-1 cursor-help">
|
||||
<span className="ml-1 cursor-help text-muted-foreground">
|
||||
<HelpCircleIcon className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
@@ -716,7 +834,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
>
|
||||
{signers.map((signer, index) => (
|
||||
<Draggable
|
||||
key={`${signer.id}-${signer.signingOrder}`}
|
||||
key={`${signer.nativeId}-${signer.signingOrder}`}
|
||||
draggableId={signer['nativeId']}
|
||||
index={index}
|
||||
isDragDisabled={
|
||||
@@ -732,7 +850,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
className={cn('py-1', {
|
||||
'bg-widget-foreground pointer-events-none rounded-md pt-2':
|
||||
'pointer-events-none rounded-md bg-widget-foreground pt-2':
|
||||
snapshot.isDragging,
|
||||
})}
|
||||
>
|
||||
@@ -992,6 +1110,14 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
onOpenChange={setShowSigningOrderConfirmation}
|
||||
onConfirm={handleSigningOrderDisable}
|
||||
/>
|
||||
|
||||
<AiRecipientDetectionDialog
|
||||
open={isAiDialogOpen}
|
||||
onOpenChange={onAiDialogOpenChange}
|
||||
onComplete={onAiDetectionComplete}
|
||||
envelopeId={envelope.id}
|
||||
teamId={envelope.teamId}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import type { DropResult } from '@hello-pangea/dnd';
|
||||
import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import type { DropResult } from '@hello-pangea/dnd';
|
||||
import { msg, plural } from '@lingui/core/macro';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { DocumentStatus } from '@prisma/client';
|
||||
import { FileWarningIcon, GripVerticalIcon, Loader2, X } from 'lucide-react';
|
||||
import { FileWarningIcon, GripVerticalIcon, Loader2 } from 'lucide-react';
|
||||
import { X } from 'lucide-react';
|
||||
import { ErrorCode as DropzoneErrorCode, type FileRejection } from 'react-dropzone';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
@@ -173,6 +174,7 @@ export const EnvelopeEditorUploadPage = () => {
|
||||
fields: envelope.fields.filter((field) => field.envelopeItemId !== envelopeItemId),
|
||||
});
|
||||
|
||||
// Reset editor fields.
|
||||
editorFields.resetForm(fieldsWithoutDeletedItem);
|
||||
};
|
||||
|
||||
@@ -224,7 +226,12 @@ export const EnvelopeEditorUploadPage = () => {
|
||||
}
|
||||
|
||||
if (maximumEnvelopeItemCount <= localFiles.length) {
|
||||
return msg`You cannot upload more than ${maximumEnvelopeItemCount} items per envelope.`;
|
||||
return msg({
|
||||
message: plural(maximumEnvelopeItemCount, {
|
||||
one: `You cannot upload more than # item per envelope.`,
|
||||
other: `You cannot upload more than # items per envelope.`,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -238,7 +245,10 @@ export const EnvelopeEditorUploadPage = () => {
|
||||
|
||||
if (maxItemsReached) {
|
||||
toast({
|
||||
title: t`You cannot upload more than ${maximumEnvelopeItemCount} items per envelope.`,
|
||||
title: plural(maximumEnvelopeItemCount, {
|
||||
one: `You cannot upload more than # item per envelope.`,
|
||||
other: `You cannot upload more than # items per envelope.`,
|
||||
}),
|
||||
duration: 5000,
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
@@ -152,30 +152,30 @@ export default function EnvelopeEditor() {
|
||||
envelopeEditorSteps.find((step) => step.id === currentStep) || envelopeEditorSteps[0];
|
||||
|
||||
return (
|
||||
<div className="dark:bg-background h-screen w-screen bg-gray-50">
|
||||
<div className="h-screen w-screen bg-gray-50 dark:bg-background">
|
||||
<EnvelopeEditorHeader />
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex h-[calc(100vh-4rem)] w-screen">
|
||||
{/* Left Section - Step Navigation */}
|
||||
<div className="bg-background border-border flex w-80 flex-shrink-0 flex-col overflow-y-auto border-r py-4">
|
||||
<div className="flex w-80 flex-shrink-0 flex-col overflow-y-auto border-r border-border bg-background py-4">
|
||||
{/* Left section step selector. */}
|
||||
<div className="px-4">
|
||||
<h3 className="text-foreground flex items-end justify-between text-sm font-semibold">
|
||||
<h3 className="flex items-end justify-between text-sm font-semibold text-foreground">
|
||||
{isDocument ? <Trans>Document Editor</Trans> : <Trans>Template Editor</Trans>}
|
||||
|
||||
<span className="text-muted-foreground bg-muted/50 ml-2 rounded border px-2 py-0.5 text-xs">
|
||||
<span className="ml-2 rounded border bg-muted/50 px-2 py-0.5 text-xs text-muted-foreground">
|
||||
<Trans context="The step counter">
|
||||
Step {currentStepData.order}/{envelopeEditorSteps.length}
|
||||
</Trans>
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
<div className="bg-muted relative my-4 h-[4px] rounded-md">
|
||||
<div className="relative my-4 h-[4px] rounded-md bg-muted">
|
||||
<motion.div
|
||||
layout="size"
|
||||
layoutId="document-flow-container-step"
|
||||
className="bg-documenso absolute inset-y-0 left-0"
|
||||
className="absolute inset-y-0 left-0 bg-documenso"
|
||||
style={{
|
||||
width: `${(100 / envelopeEditorSteps.length) * (currentStepData.order ?? 0)}%`,
|
||||
}}
|
||||
@@ -219,7 +219,7 @@ export default function EnvelopeEditor() {
|
||||
>
|
||||
{t(step.title)}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">{t(step.description)}</div>
|
||||
<div className="text-xs text-muted-foreground">{t(step.description)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -232,7 +232,7 @@ export default function EnvelopeEditor() {
|
||||
|
||||
{/* Quick Actions. */}
|
||||
<div className="space-y-3 px-4">
|
||||
<h4 className="text-foreground text-sm font-semibold">
|
||||
<h4 className="text-sm font-semibold text-foreground">
|
||||
<Trans>Quick Actions</Trans>
|
||||
</h4>
|
||||
<EnvelopeEditorSettingsDialog
|
||||
@@ -246,10 +246,6 @@ export default function EnvelopeEditor() {
|
||||
|
||||
{isDocument && (
|
||||
<EnvelopeDistributeDialog
|
||||
envelope={{
|
||||
...envelope,
|
||||
fields: editorFields.localFields,
|
||||
}}
|
||||
documentRootPath={relativePath.documentRootPath}
|
||||
trigger={
|
||||
<Button variant="ghost" size="sm" className="w-full justify-start">
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { type ReactNode, useState } from 'react';
|
||||
|
||||
import { plural } from '@lingui/core/macro';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
import { Loader } from 'lucide-react';
|
||||
import {
|
||||
@@ -27,13 +28,7 @@ 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;
|
||||
@@ -58,10 +53,6 @@ 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) ??
|
||||
@@ -70,7 +61,6 @@ 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;
|
||||
|
||||
@@ -119,15 +109,16 @@ 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);
|
||||
|
||||
const aiQueryParam = team.preferences.aiFeaturesEnabled ? '?ai=true' : '';
|
||||
|
||||
await navigate(`${pathPrefix}/${id}/edit${aiQueryParam}`);
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
@@ -144,7 +135,7 @@ export const EnvelopeDropZoneWrapper = ({
|
||||
.otherwise(() => t`An error occurred during upload.`);
|
||||
|
||||
toast({
|
||||
title: t`Upload failed`,
|
||||
title: t`Error`,
|
||||
description: errorMessage,
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
@@ -165,7 +156,10 @@ export const EnvelopeDropZoneWrapper = ({
|
||||
|
||||
if (maxItemsReached) {
|
||||
toast({
|
||||
title: t`You cannot upload more than ${maximumEnvelopeItemCount} items per envelope.`,
|
||||
title: plural(maximumEnvelopeItemCount, {
|
||||
one: `You cannot upload more than # item per envelope.`,
|
||||
other: `You cannot upload more than # items per envelope.`,
|
||||
}),
|
||||
duration: 5000,
|
||||
variant: 'destructive',
|
||||
});
|
||||
@@ -173,6 +167,7 @@ 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) {
|
||||
@@ -212,116 +207,6 @@ 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'],
|
||||
@@ -341,9 +226,9 @@ export const EnvelopeDropZoneWrapper = ({
|
||||
{children}
|
||||
|
||||
{isDragActive && (
|
||||
<div className="bg-muted/60 fixed left-0 top-0 z-[9999] h-full w-full backdrop-blur-[4px]">
|
||||
<div className="fixed left-0 top-0 z-[9999] h-full w-full bg-muted/60 backdrop-blur-[4px]">
|
||||
<div className="pointer-events-none flex h-full w-full flex-col items-center justify-center">
|
||||
<h2 className="text-foreground text-2xl font-semibold">
|
||||
<h2 className="text-2xl font-semibold text-foreground">
|
||||
{type === EnvelopeType.DOCUMENT ? (
|
||||
<Trans>Upload Document</Trans>
|
||||
) : (
|
||||
@@ -351,7 +236,7 @@ export const EnvelopeDropZoneWrapper = ({
|
||||
)}
|
||||
</h2>
|
||||
|
||||
<p className="text-muted-foreground text-md mt-4">
|
||||
<p className="text-md mt-4 text-muted-foreground">
|
||||
<Trans>Drag and drop your PDF file here</Trans>
|
||||
</p>
|
||||
|
||||
@@ -368,7 +253,7 @@ export const EnvelopeDropZoneWrapper = ({
|
||||
team?.id === undefined &&
|
||||
remaining.documents > 0 &&
|
||||
Number.isFinite(remaining.documents) && (
|
||||
<p className="text-muted-foreground/80 mt-4 text-sm">
|
||||
<p className="mt-4 text-sm text-muted-foreground/80">
|
||||
<Trans>
|
||||
{remaining.documents} of {quota.documents} documents remaining this month.
|
||||
</Trans>
|
||||
@@ -379,24 +264,15 @@ export const EnvelopeDropZoneWrapper = ({
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="bg-muted/30 absolute inset-0 z-50 backdrop-blur-[2px]">
|
||||
<div className="absolute inset-0 z-50 bg-muted/30 backdrop-blur-[2px]">
|
||||
<div className="pointer-events-none flex h-1/2 w-full flex-col items-center justify-center">
|
||||
<Loader className="text-primary h-12 w-12 animate-spin" />
|
||||
<p className="text-foreground mt-8 font-medium">
|
||||
<Loader className="h-12 w-12 animate-spin text-primary" />
|
||||
<p className="mt-8 font-medium text-foreground">
|
||||
<Trans>Uploading</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<RecipientDetectionPromptDialog
|
||||
open={showExtractionPrompt}
|
||||
onOpenChange={handlePromptDialogOpenChange}
|
||||
onAccept={handleStartRecipientDetection}
|
||||
onSkip={handleSkipRecipientDetection}
|
||||
recipients={pendingRecipients}
|
||||
onRecipientsSubmit={handleRecipientsConfirm}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -27,14 +27,7 @@ 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;
|
||||
@@ -42,6 +35,9 @@ export type EnvelopeUploadButtonProps = {
|
||||
folderId?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Upload an envelope
|
||||
*/
|
||||
export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUploadButtonProps) => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
@@ -59,14 +55,8 @@ 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) {
|
||||
@@ -118,26 +108,18 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
|
||||
? formatDocumentsPath(team.url)
|
||||
: formatTemplatesPath(team.url);
|
||||
|
||||
if (type === EnvelopeType.DOCUMENT) {
|
||||
setUploadedDocumentId(id);
|
||||
setPendingRecipients(null);
|
||||
setShouldNavigateAfterPromptClose(true);
|
||||
setShowExtractionPrompt(true);
|
||||
const aiQueryParam = team.preferences.aiFeaturesEnabled ? '?ai=true' : '';
|
||||
|
||||
toast({
|
||||
title: t`Document uploaded`,
|
||||
description: t`Your document has been uploaded successfully.`,
|
||||
duration: 5000,
|
||||
});
|
||||
} else {
|
||||
await navigate(`${pathPrefix}/${id}/edit`);
|
||||
await navigate(`${pathPrefix}/${id}/edit${aiQueryParam}`);
|
||||
|
||||
toast({
|
||||
title: t`Template uploaded`,
|
||||
description: t`Your template has been uploaded successfully.`,
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
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,
|
||||
});
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
@@ -156,7 +138,7 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
|
||||
.otherwise(() => t`An error occurred while uploading your document.`);
|
||||
|
||||
toast({
|
||||
title: t`Upload failed`,
|
||||
title: t`Error`,
|
||||
description: errorMessage,
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
@@ -173,7 +155,10 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
|
||||
|
||||
if (maxItemsReached) {
|
||||
toast({
|
||||
title: t`You cannot upload more than ${maximumEnvelopeItemCount} items per envelope.`,
|
||||
title: plural(maximumEnvelopeItemCount, {
|
||||
one: `You cannot upload more than # item per envelope.`,
|
||||
other: `You cannot upload more than # items per envelope.`,
|
||||
}),
|
||||
duration: 5000,
|
||||
variant: 'destructive',
|
||||
});
|
||||
@@ -189,181 +174,6 @@ 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>
|
||||
@@ -396,17 +206,6 @@ 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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -51,7 +51,7 @@ export const AdminDashboardUsersTable = ({
|
||||
const columns = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
header: 'ID',
|
||||
header: _(msg`ID`),
|
||||
accessorKey: 'id',
|
||||
cell: ({ row }) => <div>{row.original.id}</div>,
|
||||
},
|
||||
|
||||
@@ -57,7 +57,7 @@ export const AdminDocumentRecipientItemTable = ({ recipient }: RecipientItemProp
|
||||
const columns = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
header: 'ID',
|
||||
header: _(msg`ID`),
|
||||
accessorKey: 'id',
|
||||
cell: ({ row }) => <div>{row.original.id}</div>,
|
||||
},
|
||||
|
||||
@@ -113,7 +113,11 @@ export const AdminOrganisationsTable = ({
|
||||
isPaid ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{isPaid ? t`Paid` : t`Free`}
|
||||
{isPaid ? (
|
||||
<Trans context="Subscription status">Paid</Trans>
|
||||
) : (
|
||||
<Trans context="Subscription status">Free</Trans>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -131,7 +135,7 @@ export const AdminOrganisationsTable = ({
|
||||
<ExternalLinkIcon className="h-4 w-4" />
|
||||
</Link>
|
||||
) : (
|
||||
'None'
|
||||
<Trans>None</Trans>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -96,11 +96,11 @@ export const DocumentLogsTable = ({ documentId }: DocumentLogsTableProps) => {
|
||||
cell: ({ row }) => <span>{formatDocumentAuditLogAction(_, row.original).description}</span>,
|
||||
},
|
||||
{
|
||||
header: 'IP Address',
|
||||
header: _(msg`IP Address`),
|
||||
accessorKey: 'ipAddress',
|
||||
},
|
||||
{
|
||||
header: 'Browser',
|
||||
header: _(msg`Browser`),
|
||||
cell: ({ row }) => {
|
||||
if (!row.original.userAgent) {
|
||||
return 'N/A';
|
||||
|
||||
@@ -104,7 +104,7 @@ export const SettingsSecurityActivityTable = () => {
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'IP Address',
|
||||
header: _(msg`IP Address`),
|
||||
accessorKey: 'ipAddress',
|
||||
cell: ({ row }) => row.original.ipAddress ?? 'N/A',
|
||||
},
|
||||
|
||||
@@ -28,19 +28,19 @@ export const UserBillingOrganisationsTable = () => {
|
||||
const getSubscriptionStatusDisplay = (status: SubscriptionStatus | undefined) => {
|
||||
return match(status)
|
||||
.with(SubscriptionStatus.ACTIVE, () => ({
|
||||
label: t`Active`,
|
||||
label: t({ message: `Active`, context: `Subscription status` }),
|
||||
variant: 'default' as const,
|
||||
}))
|
||||
.with(SubscriptionStatus.PAST_DUE, () => ({
|
||||
label: t`Past Due`,
|
||||
label: t({ message: `Past Due`, context: `Subscription status` }),
|
||||
variant: 'warning' as const,
|
||||
}))
|
||||
.with(SubscriptionStatus.INACTIVE, () => ({
|
||||
label: t`Inactive`,
|
||||
label: t({ message: `Inactive`, context: `Subscription status` }),
|
||||
variant: 'neutral' as const,
|
||||
}))
|
||||
.otherwise(() => ({
|
||||
label: t`Free`,
|
||||
label: t({ message: `Free`, context: `Subscription status` }),
|
||||
variant: 'neutral' as const,
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -10,6 +10,8 @@ import { HydratedRouter } from 'react-router/dom';
|
||||
import { extractPostHogConfig } from '@documenso/lib/constants/feature-flags';
|
||||
import { dynamicActivate } from '@documenso/lib/utils/i18n';
|
||||
|
||||
import './utils/polyfills/promise-with-resolvers';
|
||||
|
||||
function PosthogInit() {
|
||||
const postHogConfig = extractPostHogConfig();
|
||||
|
||||
|
||||
@@ -114,7 +114,7 @@ export default function AdminDocumentsPage() {
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Last updated',
|
||||
header: _(msg`Last updated`),
|
||||
accessorKey: 'updatedAt',
|
||||
cell: ({ row }) => i18n.date(row.original.updatedAt),
|
||||
},
|
||||
|
||||
@@ -152,12 +152,6 @@ export default function AdminStatsPage({ loaderData }: Route.ComponentProps) {
|
||||
<div className="mt-5 grid grid-cols-2 gap-8">
|
||||
<MonthlyActiveUsersChart title={_(msg`MAU (signed in)`)} data={monthlyActiveUsers} />
|
||||
|
||||
<MonthlyActiveUsersChart
|
||||
title={_(msg`Cumulative MAU (signed in)`)}
|
||||
data={monthlyActiveUsers}
|
||||
cummulative
|
||||
/>
|
||||
|
||||
<AdminStatsUsersWithDocumentsChart
|
||||
data={monthlyUsersWithDocuments}
|
||||
title={_(msg`MAU (created document)`)}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Loader } from 'lucide-react';
|
||||
import { useLoaderData } from 'react-router';
|
||||
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { IS_AI_FEATURES_CONFIGURED } from '@documenso/lib/constants/app';
|
||||
import { DocumentSignatureType } from '@documenso/lib/constants/document';
|
||||
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
@@ -19,9 +21,16 @@ export function meta() {
|
||||
return appMetaTags('Document Preferences');
|
||||
}
|
||||
|
||||
export default function OrganisationSettingsDocumentPage() {
|
||||
const { organisations } = useSession();
|
||||
export const loader = () => {
|
||||
return {
|
||||
isAiFeaturesConfigured: IS_AI_FEATURES_CONFIGURED(),
|
||||
};
|
||||
};
|
||||
|
||||
export default function OrganisationSettingsDocumentPage() {
|
||||
const { isAiFeaturesConfigured } = useLoaderData<typeof loader>();
|
||||
|
||||
const { organisations } = useSession();
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const { t } = useLingui();
|
||||
@@ -48,6 +57,7 @@ export default function OrganisationSettingsDocumentPage() {
|
||||
includeSigningCertificate,
|
||||
includeAuditLog,
|
||||
signatureTypes,
|
||||
aiFeaturesEnabled,
|
||||
} = data;
|
||||
|
||||
if (
|
||||
@@ -56,7 +66,8 @@ export default function OrganisationSettingsDocumentPage() {
|
||||
documentDateFormat === null ||
|
||||
includeSenderDetails === null ||
|
||||
includeSigningCertificate === null ||
|
||||
includeAuditLog === null
|
||||
includeAuditLog === null ||
|
||||
aiFeaturesEnabled === null
|
||||
) {
|
||||
throw new Error('Should not be possible.');
|
||||
}
|
||||
@@ -74,6 +85,7 @@ export default function OrganisationSettingsDocumentPage() {
|
||||
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||
aiFeaturesEnabled,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -93,7 +105,7 @@ export default function OrganisationSettingsDocumentPage() {
|
||||
if (isLoadingOrganisation || !organisationWithSettings) {
|
||||
return (
|
||||
<div className="flex items-center justify-center rounded-lg py-32">
|
||||
<Loader className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||
<Loader className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -110,6 +122,7 @@ export default function OrganisationSettingsDocumentPage() {
|
||||
<section>
|
||||
<DocumentPreferencesForm
|
||||
canInherit={false}
|
||||
isAiFeaturesConfigured={isAiFeaturesConfigured}
|
||||
settings={organisationWithSettings.organisationGlobalSettings}
|
||||
onFormSubmit={onDocumentPreferencesFormSubmit}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Loader } from 'lucide-react';
|
||||
import { useLoaderData } from 'react-router';
|
||||
|
||||
import { IS_AI_FEATURES_CONFIGURED } from '@documenso/lib/constants/app';
|
||||
import { DocumentSignatureType } from '@documenso/lib/constants/document';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
@@ -17,7 +19,17 @@ export function meta() {
|
||||
return appMetaTags('Document Preferences');
|
||||
}
|
||||
|
||||
export const loader = () => {
|
||||
return {
|
||||
isAiFeaturesConfigured: IS_AI_FEATURES_CONFIGURED(),
|
||||
};
|
||||
};
|
||||
|
||||
export default function TeamsSettingsPage() {
|
||||
const { isAiFeaturesConfigured } = useLoaderData<typeof loader>();
|
||||
|
||||
console.log('isAiFeaturesConfigured', isAiFeaturesConfigured);
|
||||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const { t } = useLingui();
|
||||
@@ -40,6 +52,7 @@ export default function TeamsSettingsPage() {
|
||||
includeSigningCertificate,
|
||||
includeAuditLog,
|
||||
signatureTypes,
|
||||
aiFeaturesEnabled,
|
||||
} = data;
|
||||
|
||||
await updateTeamSettings({
|
||||
@@ -52,6 +65,7 @@ export default function TeamsSettingsPage() {
|
||||
includeSenderDetails,
|
||||
includeSigningCertificate,
|
||||
includeAuditLog,
|
||||
aiFeaturesEnabled,
|
||||
...(signatureTypes.length === 0
|
||||
? {
|
||||
typedSignatureEnabled: null,
|
||||
@@ -82,7 +96,7 @@ export default function TeamsSettingsPage() {
|
||||
if (isLoadingTeam || !teamWithSettings) {
|
||||
return (
|
||||
<div className="flex items-center justify-center rounded-lg py-32">
|
||||
<Loader className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||
<Loader className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -97,6 +111,7 @@ export default function TeamsSettingsPage() {
|
||||
<section>
|
||||
<DocumentPreferencesForm
|
||||
canInherit={true}
|
||||
isAiFeaturesConfigured={isAiFeaturesConfigured}
|
||||
settings={teamWithSettings.teamSettings}
|
||||
onFormSubmit={onDocumentPreferencesSubmit}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { AlertTriangle, Building2, Database, Eye, Settings, UserCircle2 } from 'lucide-react';
|
||||
import { data, isRouteErrorResponse } from 'react-router';
|
||||
@@ -125,6 +126,7 @@ export async function loader({ params }: Route.LoaderArgs) {
|
||||
export default function OrganisationSsoConfirmationTokenPage({ loaderData }: Route.ComponentProps) {
|
||||
const { token, type, user, organisation } = loaderData;
|
||||
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -136,12 +138,12 @@ export default function OrganisationSsoConfirmationTokenPage({ loaderData }: Rou
|
||||
await navigate('/');
|
||||
|
||||
toast({
|
||||
title: 'Account link declined',
|
||||
title: _(msg`Account link declined`),
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: 'Error declining account link',
|
||||
title: _(msg`Error declining account link`),
|
||||
description: error.message,
|
||||
});
|
||||
},
|
||||
@@ -153,12 +155,12 @@ export default function OrganisationSsoConfirmationTokenPage({ loaderData }: Rou
|
||||
await navigate(formatOrganisationLoginPath(organisation.url));
|
||||
|
||||
toast({
|
||||
title: 'Account linked successfully',
|
||||
title: _(msg`Account linked successfully`),
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: 'Error linking account',
|
||||
title: _(msg`Error linking account`),
|
||||
description: error.message,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
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;
|
||||
};
|
||||
@@ -1,67 +0,0 @@
|
||||
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,
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Polyfill for Promise.withResolvers (ES2024)
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/withResolvers
|
||||
*/
|
||||
|
||||
type PromiseWithResolvers<T> = {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T | PromiseLike<T>) => void;
|
||||
reject: (reason?: unknown) => void;
|
||||
};
|
||||
|
||||
// We're patching here
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const GlobalPromise = globalThis.Promise as any;
|
||||
|
||||
if (typeof GlobalPromise.withResolvers !== 'function') {
|
||||
GlobalPromise.withResolvers = function <T>(): PromiseWithResolvers<T> {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
|
||||
return { promise, resolve, reject };
|
||||
};
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -14,8 +14,6 @@
|
||||
"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.3",
|
||||
"@documenso/api": "*",
|
||||
"@documenso/assets": "*",
|
||||
@@ -43,7 +41,6 @@
|
||||
"@simplewebauthn/browser": "^9.0.1",
|
||||
"@simplewebauthn/server": "^9.0.3",
|
||||
"@tanstack/react-query": "5.90.10",
|
||||
"ai": "^5.0.82",
|
||||
"autoprefixer": "^10.4.22",
|
||||
"colord": "^2.9.3",
|
||||
"content-disposition": "^1.0.1",
|
||||
@@ -74,7 +71,6 @@
|
||||
"remix-themes": "^2.0.4",
|
||||
"satori": "^0.18.3",
|
||||
"sharp": "0.34.5",
|
||||
"skia-canvas": "^3.0.8",
|
||||
"tailwindcss": "^3.4.18",
|
||||
"ts-pattern": "^5.9.0",
|
||||
"ua-parser-js": "^1.0.41",
|
||||
@@ -112,5 +108,5 @@
|
||||
"vite-plugin-babel-macros": "^1.0.6",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
},
|
||||
"version": "2.1.0"
|
||||
"version": "2.2.3"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
type TDetectFieldsRequest,
|
||||
ZNormalizedFieldWithContextSchema,
|
||||
} from './detect-fields.types';
|
||||
|
||||
export type { TDetectFieldsRequest };
|
||||
|
||||
// Stream event schemas
|
||||
const ZProgressEventSchema = z.object({
|
||||
type: z.literal('progress'),
|
||||
pagesProcessed: z.number(),
|
||||
totalPages: z.number(),
|
||||
fieldsDetected: z.number(),
|
||||
});
|
||||
|
||||
const ZKeepaliveEventSchema = z.object({
|
||||
type: z.literal('keepalive'),
|
||||
});
|
||||
|
||||
const ZErrorEventSchema = z.object({
|
||||
type: z.literal('error'),
|
||||
message: z.string(),
|
||||
});
|
||||
|
||||
const ZCompleteEventSchema = z.object({
|
||||
type: z.literal('complete'),
|
||||
fields: z.array(ZNormalizedFieldWithContextSchema),
|
||||
});
|
||||
|
||||
const ZStreamEventSchema = z.discriminatedUnion('type', [
|
||||
ZProgressEventSchema,
|
||||
ZKeepaliveEventSchema,
|
||||
ZErrorEventSchema,
|
||||
ZCompleteEventSchema,
|
||||
]);
|
||||
|
||||
export type DetectFieldsProgressEvent = z.infer<typeof ZProgressEventSchema>;
|
||||
export type DetectFieldsCompleteEvent = z.infer<typeof ZCompleteEventSchema>;
|
||||
export type DetectFieldsStreamEvent = z.infer<typeof ZStreamEventSchema>;
|
||||
|
||||
const ZApiErrorResponseSchema = z.object({
|
||||
error: z.string(),
|
||||
});
|
||||
|
||||
export class AiApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public status: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'AiApiError';
|
||||
}
|
||||
}
|
||||
|
||||
export type DetectFieldsOptions = {
|
||||
request: TDetectFieldsRequest;
|
||||
onProgress?: (event: DetectFieldsProgressEvent) => void;
|
||||
onComplete: (event: DetectFieldsCompleteEvent) => void;
|
||||
onError: (error: AiApiError) => void;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
/**
|
||||
* Detect fields from an envelope using AI with streaming support.
|
||||
*/
|
||||
export const detectFields = async ({
|
||||
request,
|
||||
onProgress,
|
||||
onComplete,
|
||||
onError,
|
||||
signal,
|
||||
}: DetectFieldsOptions): Promise<void> => {
|
||||
const response = await fetch('/api/ai/detect-fields', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
signal,
|
||||
});
|
||||
|
||||
// Handle non-streaming error responses (auth failures, etc.)
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
|
||||
try {
|
||||
const parsed = ZApiErrorResponseSchema.parse(JSON.parse(text));
|
||||
|
||||
throw new AiApiError(parsed.error, response.status);
|
||||
} catch (e) {
|
||||
if (e instanceof AiApiError) {
|
||||
throw e;
|
||||
}
|
||||
|
||||
throw new AiApiError('Failed to detect fields', response.status);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle streaming response
|
||||
const reader = response.body?.getReader();
|
||||
|
||||
if (!reader) {
|
||||
throw new AiApiError('No response body', 500);
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
try {
|
||||
let done = false;
|
||||
|
||||
while (!done) {
|
||||
const result = await reader.read();
|
||||
done = result.done;
|
||||
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
const value = result.value;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// Process complete lines
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || ''; // Keep incomplete line in buffer
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const event = ZStreamEventSchema.parse(JSON.parse(line));
|
||||
|
||||
switch (event.type) {
|
||||
case 'progress':
|
||||
onProgress?.(event);
|
||||
break;
|
||||
case 'keepalive':
|
||||
// Ignore keepalive, it's just to prevent timeout
|
||||
break;
|
||||
case 'error':
|
||||
onError(new AiApiError(event.message, 500));
|
||||
return;
|
||||
case 'complete':
|
||||
onComplete(event);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed lines
|
||||
console.warn('Failed to parse stream event:', line);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,154 @@
|
||||
import { sValidator } from '@hono/standard-validator';
|
||||
import { Hono } from 'hono';
|
||||
import { streamText } from 'hono/streaming';
|
||||
|
||||
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { IS_AI_FEATURES_CONFIGURED } from '@documenso/lib/constants/app';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { detectFieldsFromEnvelope } from '@documenso/lib/server-only/ai/envelope/detect-fields';
|
||||
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
|
||||
|
||||
import type { HonoEnv } from '../../router';
|
||||
import { ZDetectFieldsRequestSchema } from './detect-fields.types';
|
||||
|
||||
const KEEPALIVE_INTERVAL_MS = 5000;
|
||||
|
||||
export const detectFieldsRoute = new Hono<HonoEnv>().post(
|
||||
'/',
|
||||
sValidator('json', ZDetectFieldsRequestSchema),
|
||||
async (c) => {
|
||||
const logger = c.get('logger');
|
||||
|
||||
try {
|
||||
const { envelopeId, teamId, context } = c.req.valid('json');
|
||||
|
||||
const session = await getSession(c);
|
||||
|
||||
if (!session.user) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You must be logged in to detect fields',
|
||||
});
|
||||
}
|
||||
|
||||
// Verify user has access to the team (abort early)
|
||||
const team = await getTeamById({
|
||||
userId: session.user.id,
|
||||
teamId,
|
||||
}).catch(() => null);
|
||||
|
||||
if (!team) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have access to this team',
|
||||
});
|
||||
}
|
||||
|
||||
// Check if AI features are enabled for the team
|
||||
const { aiFeaturesEnabled } = team.derivedSettings;
|
||||
|
||||
if (!aiFeaturesEnabled) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'AI features are not enabled for this team',
|
||||
});
|
||||
}
|
||||
|
||||
if (!IS_AI_FEATURES_CONFIGURED()) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'AI features are not configured. Please contact support to enable AI features.',
|
||||
});
|
||||
}
|
||||
|
||||
logger.info({
|
||||
event: 'ai.detect-fields.start',
|
||||
envelopeId,
|
||||
userId: session.user.id,
|
||||
teamId: team.id,
|
||||
hasContext: !!context,
|
||||
});
|
||||
|
||||
// Return streaming response with NDJSON
|
||||
return streamText(c, async (stream) => {
|
||||
// Start keepalive to prevent connection timeout
|
||||
let interval: NodeJS.Timeout | null = setInterval(() => {
|
||||
void stream.writeln(JSON.stringify({ type: 'keepalive' }));
|
||||
}, KEEPALIVE_INTERVAL_MS);
|
||||
|
||||
try {
|
||||
const allFields = await detectFieldsFromEnvelope({
|
||||
context,
|
||||
envelopeId,
|
||||
userId: session.user.id,
|
||||
teamId: team.id,
|
||||
onProgress: (progress) => {
|
||||
void stream.writeln(
|
||||
JSON.stringify({
|
||||
type: 'progress',
|
||||
pagesProcessed: progress.pagesProcessed,
|
||||
totalPages: progress.totalPages,
|
||||
fieldsDetected: progress.fieldsDetected,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// Clear keepalive before sending final response
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
interval = null;
|
||||
}
|
||||
|
||||
logger.info({
|
||||
event: 'ai.detect-fields.complete',
|
||||
envelopeId,
|
||||
userId: session.user.id,
|
||||
teamId: team.id,
|
||||
fieldCount: allFields.length,
|
||||
});
|
||||
|
||||
await stream.writeln(
|
||||
JSON.stringify({
|
||||
type: 'complete',
|
||||
fields: allFields,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
// Clear keepalive on error
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
|
||||
// The logger below it stringifies the error, using `console.error`
|
||||
// to attempt to get a stack trace
|
||||
console.error(error);
|
||||
|
||||
logger.error({
|
||||
event: 'ai.detect-fields.error',
|
||||
error,
|
||||
});
|
||||
|
||||
const message = error instanceof AppError ? error.message : 'Failed to detect fields';
|
||||
|
||||
await stream.writeln(
|
||||
JSON.stringify({
|
||||
type: 'error',
|
||||
message,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
// Handle errors that occur before streaming starts
|
||||
logger.error({
|
||||
event: 'ai.detect-fields.error',
|
||||
error,
|
||||
});
|
||||
|
||||
if (error instanceof AppError) {
|
||||
const { status, body } = AppError.toRestAPIError(error);
|
||||
|
||||
return c.json(body, status);
|
||||
}
|
||||
|
||||
return c.json({ error: 'Failed to detect fields' }, 500);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,54 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
ZConfidenceLevel,
|
||||
ZDetectableFieldType,
|
||||
} from '@documenso/lib/server-only/ai/envelope/detect-fields/schema';
|
||||
|
||||
export const ZDetectFieldsRequestSchema = z.object({
|
||||
envelopeId: z.string().min(1).describe('The ID of the envelope to detect fields from.'),
|
||||
teamId: z.number().describe('The ID of the team the envelope belongs to.'),
|
||||
context: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Optional context about recipients to help map fields (e.g., "David is the Employee, Lucas is the Manager").',
|
||||
),
|
||||
});
|
||||
|
||||
export type TDetectFieldsRequest = z.infer<typeof ZDetectFieldsRequestSchema>;
|
||||
|
||||
// Schema for fields returned from streaming API (before recipient resolution)
|
||||
export const ZNormalizedFieldWithPageSchema = z.object({
|
||||
type: ZDetectableFieldType,
|
||||
recipientKey: z.string(),
|
||||
positionX: z.number(),
|
||||
positionY: z.number(),
|
||||
width: z.number(),
|
||||
height: z.number(),
|
||||
confidence: ZConfidenceLevel,
|
||||
pageNumber: z.number(),
|
||||
});
|
||||
|
||||
export type TNormalizedFieldWithPage = z.infer<typeof ZNormalizedFieldWithPageSchema>;
|
||||
|
||||
// Schema for fields after recipient resolution
|
||||
export const ZNormalizedFieldWithContextSchema = z.object({
|
||||
type: ZDetectableFieldType,
|
||||
positionX: z.number(),
|
||||
positionY: z.number(),
|
||||
width: z.number(),
|
||||
height: z.number(),
|
||||
confidence: ZConfidenceLevel,
|
||||
pageNumber: z.number(),
|
||||
recipientId: z.number(),
|
||||
envelopeItemId: z.string(),
|
||||
});
|
||||
|
||||
export type TNormalizedFieldWithContext = z.infer<typeof ZNormalizedFieldWithContextSchema>;
|
||||
|
||||
export const ZDetectFieldsResponseSchema = z.object({
|
||||
fields: z.array(ZNormalizedFieldWithContextSchema),
|
||||
});
|
||||
|
||||
export type TDetectFieldsResponse = z.infer<typeof ZDetectFieldsResponseSchema>;
|
||||
@@ -0,0 +1,160 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZDetectedRecipientSchema } from '@documenso/lib/server-only/ai/envelope/detect-recipients/schema';
|
||||
|
||||
import { type TDetectRecipientsRequest } from './detect-recipients.types';
|
||||
|
||||
export type { TDetectRecipientsRequest };
|
||||
|
||||
// Stream event schemas
|
||||
const ZProgressEventSchema = z.object({
|
||||
type: z.literal('progress'),
|
||||
pagesProcessed: z.number(),
|
||||
totalPages: z.number(),
|
||||
recipientsDetected: z.number(),
|
||||
});
|
||||
|
||||
const ZKeepaliveEventSchema = z.object({
|
||||
type: z.literal('keepalive'),
|
||||
});
|
||||
|
||||
const ZErrorEventSchema = z.object({
|
||||
type: z.literal('error'),
|
||||
message: z.string(),
|
||||
});
|
||||
|
||||
const ZCompleteEventSchema = z.object({
|
||||
type: z.literal('complete'),
|
||||
recipients: z.array(ZDetectedRecipientSchema),
|
||||
});
|
||||
|
||||
const ZStreamEventSchema = z.discriminatedUnion('type', [
|
||||
ZProgressEventSchema,
|
||||
ZKeepaliveEventSchema,
|
||||
ZErrorEventSchema,
|
||||
ZCompleteEventSchema,
|
||||
]);
|
||||
|
||||
export type DetectRecipientsProgressEvent = z.infer<typeof ZProgressEventSchema>;
|
||||
export type DetectRecipientsCompleteEvent = z.infer<typeof ZCompleteEventSchema>;
|
||||
export type DetectRecipientsStreamEvent = z.infer<typeof ZStreamEventSchema>;
|
||||
|
||||
const ZApiErrorResponseSchema = z.object({
|
||||
error: z.string(),
|
||||
});
|
||||
|
||||
export class AiApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public status: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'AiApiError';
|
||||
}
|
||||
}
|
||||
|
||||
export type DetectRecipientsOptions = {
|
||||
request: TDetectRecipientsRequest;
|
||||
onProgress?: (event: DetectRecipientsProgressEvent) => void;
|
||||
onComplete: (event: DetectRecipientsCompleteEvent) => void;
|
||||
onError: (error: AiApiError) => void;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
/**
|
||||
* Detect recipients from an envelope using AI with streaming support.
|
||||
*/
|
||||
export const detectRecipients = async ({
|
||||
request,
|
||||
onProgress,
|
||||
onComplete,
|
||||
onError,
|
||||
signal,
|
||||
}: DetectRecipientsOptions): Promise<void> => {
|
||||
const response = await fetch('/api/ai/detect-recipients', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
signal,
|
||||
});
|
||||
|
||||
// Handle non-streaming error responses (auth failures, etc.)
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
|
||||
try {
|
||||
const parsed = ZApiErrorResponseSchema.parse(JSON.parse(text));
|
||||
|
||||
throw new AiApiError(parsed.error, response.status);
|
||||
} catch (e) {
|
||||
if (e instanceof AiApiError) {
|
||||
throw e;
|
||||
}
|
||||
|
||||
throw new AiApiError('Failed to detect recipients', response.status);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle streaming response
|
||||
const reader = response.body?.getReader();
|
||||
|
||||
if (!reader) {
|
||||
throw new AiApiError('No response body', 500);
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
try {
|
||||
let done = false;
|
||||
|
||||
while (!done) {
|
||||
const result = await reader.read();
|
||||
done = result.done;
|
||||
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
const value = result.value;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// Process complete lines
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || ''; // Keep incomplete line in buffer
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const event = ZStreamEventSchema.parse(JSON.parse(line));
|
||||
|
||||
switch (event.type) {
|
||||
case 'progress':
|
||||
onProgress?.(event);
|
||||
break;
|
||||
case 'keepalive':
|
||||
// Ignore keepalive, it's just to prevent timeout
|
||||
break;
|
||||
case 'error':
|
||||
onError(new AiApiError(event.message, 500));
|
||||
return;
|
||||
case 'complete':
|
||||
onComplete(event);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed lines
|
||||
console.warn('Failed to parse stream event:', line);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,152 @@
|
||||
import { sValidator } from '@hono/standard-validator';
|
||||
import { Hono } from 'hono';
|
||||
import { streamText } from 'hono/streaming';
|
||||
|
||||
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { IS_AI_FEATURES_CONFIGURED } from '@documenso/lib/constants/app';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { detectRecipientsFromEnvelope } from '@documenso/lib/server-only/ai/envelope/detect-recipients';
|
||||
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
|
||||
|
||||
import type { HonoEnv } from '../../router';
|
||||
import { ZDetectRecipientsRequestSchema } from './detect-recipients.types';
|
||||
|
||||
const KEEPALIVE_INTERVAL_MS = 5000;
|
||||
|
||||
export const detectRecipientsRoute = new Hono<HonoEnv>().post(
|
||||
'/',
|
||||
sValidator('json', ZDetectRecipientsRequestSchema),
|
||||
async (c) => {
|
||||
const logger = c.get('logger');
|
||||
|
||||
try {
|
||||
const { envelopeId, teamId } = c.req.valid('json');
|
||||
|
||||
const session = await getSession(c);
|
||||
|
||||
if (!session.user) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You must be logged in to detect recipients',
|
||||
});
|
||||
}
|
||||
|
||||
// Verify user has access to the team (abort early)
|
||||
const team = await getTeamById({
|
||||
userId: session.user.id,
|
||||
teamId,
|
||||
}).catch(() => null);
|
||||
|
||||
if (!team) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have access to this team',
|
||||
});
|
||||
}
|
||||
|
||||
// Check if AI features are enabled for the team
|
||||
const { aiFeaturesEnabled } = team.derivedSettings;
|
||||
|
||||
if (!aiFeaturesEnabled) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'AI features are not enabled for this team',
|
||||
});
|
||||
}
|
||||
|
||||
if (!IS_AI_FEATURES_CONFIGURED()) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'AI features are not configured. Please contact support to enable AI features.',
|
||||
});
|
||||
}
|
||||
|
||||
logger.info({
|
||||
event: 'ai.detect-recipients.start',
|
||||
envelopeId,
|
||||
userId: session.user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
// Return streaming response with NDJSON
|
||||
return streamText(c, async (stream) => {
|
||||
// Start keepalive to prevent connection timeout
|
||||
let interval: NodeJS.Timeout | null = setInterval(() => {
|
||||
void stream.writeln(JSON.stringify({ type: 'keepalive' }));
|
||||
}, KEEPALIVE_INTERVAL_MS);
|
||||
|
||||
try {
|
||||
const recipients = await detectRecipientsFromEnvelope({
|
||||
envelopeId,
|
||||
userId: session.user.id,
|
||||
teamId: team.id,
|
||||
onProgress: (progress) => {
|
||||
void stream.writeln(
|
||||
JSON.stringify({
|
||||
type: 'progress',
|
||||
pagesProcessed: progress.pagesProcessed,
|
||||
totalPages: progress.totalPages,
|
||||
recipientsDetected: progress.recipientsDetected,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// Clear keepalive before sending final response
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
interval = null;
|
||||
}
|
||||
|
||||
logger.info({
|
||||
event: 'ai.detect-recipients.complete',
|
||||
envelopeId,
|
||||
userId: session.user.id,
|
||||
teamId: team.id,
|
||||
recipientCount: recipients.length,
|
||||
});
|
||||
|
||||
await stream.writeln(
|
||||
JSON.stringify({
|
||||
type: 'complete',
|
||||
recipients,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
// Clear keepalive on error
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
|
||||
// The logger below it stringifies the error, using `console.error`
|
||||
// to attempt to get a stack trace
|
||||
console.error(error);
|
||||
|
||||
logger.error({
|
||||
event: 'ai.detect-recipients.error',
|
||||
error,
|
||||
});
|
||||
|
||||
const message = error instanceof AppError ? error.message : 'Failed to detect recipients';
|
||||
|
||||
await stream.writeln(
|
||||
JSON.stringify({
|
||||
type: 'error',
|
||||
message,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
// Handle errors that occur before streaming starts
|
||||
logger.error({
|
||||
event: 'ai.detect-recipients.error',
|
||||
error,
|
||||
});
|
||||
|
||||
if (error instanceof AppError) {
|
||||
const { status, body } = AppError.toRestAPIError(error);
|
||||
|
||||
return c.json(body, status);
|
||||
}
|
||||
|
||||
return c.json({ error: 'Failed to detect recipients' }, 500);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,16 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZDetectedRecipientSchema } from '@documenso/lib/server-only/ai/envelope/detect-recipients/schema';
|
||||
|
||||
export const ZDetectRecipientsRequestSchema = z.object({
|
||||
envelopeId: z.string().min(1).describe('The ID of the envelope to detect recipients from.'),
|
||||
teamId: z.number().describe('The ID of the team the envelope belongs to.'),
|
||||
});
|
||||
|
||||
export type TDetectRecipientsRequest = z.infer<typeof ZDetectRecipientsRequestSchema>;
|
||||
|
||||
export const ZDetectRecipientsResponseSchema = z.object({
|
||||
recipients: z.array(ZDetectedRecipientSchema),
|
||||
});
|
||||
|
||||
export type TDetectRecipientsResponse = z.infer<typeof ZDetectRecipientsResponseSchema>;
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Hono } from 'hono';
|
||||
|
||||
import type { HonoEnv } from '../../router';
|
||||
import { detectFieldsRoute } from './detect-fields';
|
||||
import { detectRecipientsRoute } from './detect-recipients';
|
||||
|
||||
export const aiRoute = new Hono<HonoEnv>()
|
||||
.route('/detect-recipients', detectRecipientsRoute)
|
||||
.route('/detect-fields', detectFieldsRoute);
|
||||
@@ -1,61 +0,0 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { DocumentData } from '@documenso/prisma/client';
|
||||
|
||||
/**
|
||||
* Authorize a user's access to an envelope and return its document data.
|
||||
* Checks both direct ownership and team membership.
|
||||
*/
|
||||
export async function authorizeDocumentAccess(
|
||||
envelopeId: string,
|
||||
userId: number,
|
||||
): Promise<DocumentData> {
|
||||
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;
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { Canvas, Image } from 'skia-canvas';
|
||||
|
||||
import type { TDetectFormFieldsResponse } from './types';
|
||||
|
||||
export type RenderedPage = {
|
||||
image: Buffer;
|
||||
pageNumber: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
const GRID_PADDING = { left: 80, top: 20, right: 20, bottom: 40 };
|
||||
const GRID_INTERVAL = 100;
|
||||
const FIELD_COLORS = ['#FF0000', '#00FF00', '#0000FF', '#FFFF00', '#FF00FF', '#00FFFF'];
|
||||
|
||||
/**
|
||||
* Saves debug visualizations of detected form fields for development purposes.
|
||||
* Creates annotated images showing field bounding boxes and coordinate grids.
|
||||
*/
|
||||
export async function saveDebugVisualization(
|
||||
renderedPages: RenderedPage[],
|
||||
detectedFields: TDetectFormFieldsResponse,
|
||||
): Promise<void> {
|
||||
const debugDir = join(process.cwd(), '..', '..', 'packages', 'assets', 'ai-previews');
|
||||
await mkdir(debugDir, { recursive: true });
|
||||
|
||||
const timestamp = new Date()
|
||||
.toISOString()
|
||||
.replace(/[-:]/g, '')
|
||||
.replace(/\..+/, '')
|
||||
.replace('T', '_');
|
||||
|
||||
for (const page of renderedPages) {
|
||||
const canvas = createAnnotatedCanvas(page, detectedFields);
|
||||
await saveCanvasToFile(canvas, debugDir, timestamp, page.pageNumber);
|
||||
}
|
||||
}
|
||||
|
||||
function createAnnotatedCanvas(
|
||||
page: RenderedPage,
|
||||
detectedFields: TDetectFormFieldsResponse,
|
||||
): Canvas {
|
||||
const canvas = new Canvas(
|
||||
page.width + GRID_PADDING.left + GRID_PADDING.right,
|
||||
page.height + GRID_PADDING.top + GRID_PADDING.bottom,
|
||||
);
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// Draw the original page image
|
||||
const img = new Image();
|
||||
img.src = page.image;
|
||||
ctx.drawImage(img, GRID_PADDING.left, GRID_PADDING.top);
|
||||
|
||||
// Draw coordinate grid
|
||||
drawCoordinateGrid(ctx, page.width, page.height);
|
||||
|
||||
// Draw field bounding boxes
|
||||
drawFieldBoundingBoxes(ctx, page, detectedFields);
|
||||
|
||||
// Draw axis labels
|
||||
drawAxisLabels(ctx, page.width, page.height);
|
||||
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function drawCoordinateGrid(
|
||||
ctx: ReturnType<Canvas['getContext']>,
|
||||
pageWidth: number,
|
||||
pageHeight: number,
|
||||
): void {
|
||||
ctx.strokeStyle = 'rgba(255, 0, 0, 0.5)';
|
||||
ctx.lineWidth = 1;
|
||||
|
||||
// Draw vertical grid lines
|
||||
for (let i = 0; i <= 1000; i += GRID_INTERVAL) {
|
||||
const x = GRID_PADDING.left + (i / 1000) * pageWidth;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, GRID_PADDING.top);
|
||||
ctx.lineTo(x, pageHeight + GRID_PADDING.top);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Draw horizontal grid lines
|
||||
for (let i = 0; i <= 1000; i += GRID_INTERVAL) {
|
||||
const y = GRID_PADDING.top + (i / 1000) * pageHeight;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(GRID_PADDING.left, y);
|
||||
ctx.lineTo(pageWidth + GRID_PADDING.left, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
function drawFieldBoundingBoxes(
|
||||
ctx: ReturnType<Canvas['getContext']>,
|
||||
page: RenderedPage,
|
||||
detectedFields: TDetectFormFieldsResponse,
|
||||
): void {
|
||||
const pageFields = detectedFields.filter((field) => field.pageNumber === page.pageNumber);
|
||||
|
||||
pageFields.forEach((field, index) => {
|
||||
const { ymin, xmin, ymax, xmax } = field.boundingBox;
|
||||
|
||||
const x = (xmin / 1000) * page.width + GRID_PADDING.left;
|
||||
const y = (ymin / 1000) * page.height + GRID_PADDING.top;
|
||||
const width = ((xmax - xmin) / 1000) * page.width;
|
||||
const height = ((ymax - ymin) / 1000) * page.height;
|
||||
|
||||
const color = FIELD_COLORS[index % FIELD_COLORS.length];
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 5;
|
||||
ctx.strokeRect(x, y, width, height);
|
||||
|
||||
// Draw field label
|
||||
ctx.fillStyle = color;
|
||||
ctx.font = '20px Arial';
|
||||
ctx.fillText(field.label, x, y - 5);
|
||||
});
|
||||
}
|
||||
|
||||
function drawAxisLabels(
|
||||
ctx: ReturnType<Canvas['getContext']>,
|
||||
pageWidth: number,
|
||||
pageHeight: number,
|
||||
): void {
|
||||
ctx.strokeStyle = '#000000';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.font = '26px Arial';
|
||||
|
||||
// Draw Y-axis
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(GRID_PADDING.left, GRID_PADDING.top);
|
||||
ctx.lineTo(GRID_PADDING.left, pageHeight + GRID_PADDING.top);
|
||||
ctx.stroke();
|
||||
|
||||
// Y-axis labels
|
||||
ctx.textAlign = 'right';
|
||||
ctx.textBaseline = 'middle';
|
||||
for (let i = 0; i <= 1000; i += GRID_INTERVAL) {
|
||||
const y = GRID_PADDING.top + (i / 1000) * pageHeight;
|
||||
ctx.fillStyle = '#000000';
|
||||
ctx.fillText(i.toString(), GRID_PADDING.left - 5, y);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(GRID_PADDING.left - 5, y);
|
||||
ctx.lineTo(GRID_PADDING.left, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Draw X-axis
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(GRID_PADDING.left, pageHeight + GRID_PADDING.top);
|
||||
ctx.lineTo(pageWidth + GRID_PADDING.left, pageHeight + GRID_PADDING.top);
|
||||
ctx.stroke();
|
||||
|
||||
// X-axis labels
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'top';
|
||||
for (let i = 0; i <= 1000; i += GRID_INTERVAL) {
|
||||
const x = GRID_PADDING.left + (i / 1000) * pageWidth;
|
||||
ctx.fillStyle = '#000000';
|
||||
ctx.fillText(i.toString(), x, pageHeight + GRID_PADDING.top + 5);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, pageHeight + GRID_PADDING.top);
|
||||
ctx.lineTo(x, pageHeight + GRID_PADDING.top + 5);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCanvasToFile(
|
||||
canvas: Canvas,
|
||||
debugDir: string,
|
||||
timestamp: string,
|
||||
pageNumber: number,
|
||||
): Promise<void> {
|
||||
const outputFilename = `detected_form_fields_${timestamp}_page_${pageNumber}.png`;
|
||||
const outputPath = join(debugDir, outputFilename);
|
||||
|
||||
const pngBuffer = await canvas.toBuffer('png');
|
||||
await writeFile(outputPath, new Uint8Array(pngBuffer));
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
import { generateObject } from 'ai';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { resizeAndCompressImage } from '@documenso/lib/server-only/image/resize-and-compress-image';
|
||||
|
||||
import { DETECT_OBJECTS_PROMPT } from './prompts';
|
||||
import type { TDetectFormFieldsResponse } from './types';
|
||||
import { ZDetectedFormFieldSchema } from './types';
|
||||
import { buildRecipientDirectory, safeGenerateObject, validateRecipientId } from './utils';
|
||||
|
||||
export type FieldDetectionRecipient = {
|
||||
id: number;
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
role: string;
|
||||
signingOrder: number | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the field detection prompt with optional recipient context.
|
||||
*/
|
||||
function buildFieldDetectionPrompt(recipients: FieldDetectionRecipient[]): string {
|
||||
if (recipients.length === 0) {
|
||||
return DETECT_OBJECTS_PROMPT;
|
||||
}
|
||||
|
||||
const directory = buildRecipientDirectory(recipients);
|
||||
|
||||
return `${DETECT_OBJECTS_PROMPT}
|
||||
|
||||
RECIPIENT DIRECTORY:
|
||||
${directory}
|
||||
|
||||
RECIPIENT ASSIGNMENT RULES:
|
||||
1. Every detected field MUST include a "recipientId" taken from the directory above.
|
||||
2. Match printed names, role labels ("Buyer", "Seller"), or instructions near the field to the closest recipient.
|
||||
3. When the document references numbered signers (Signer 1, Signer 2, etc.), align them with signingOrder when provided.
|
||||
4. If a name exactly matches a recipient, always use that recipient's ID.
|
||||
5. When context is ambiguous, distribute fields logically across recipients instead of assigning all fields to one person.
|
||||
6. Never invent new recipients or IDs—only use those in the directory.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run form field detection on a single page image.
|
||||
*/
|
||||
export async function runFormFieldDetection(
|
||||
imageBuffer: Buffer,
|
||||
pageNumber: number,
|
||||
recipients: FieldDetectionRecipient[],
|
||||
): Promise<TDetectFormFieldsResponse> {
|
||||
const compressedImageBuffer = await resizeAndCompressImage(imageBuffer);
|
||||
const base64Image = compressedImageBuffer.toString('base64');
|
||||
const prompt = buildFieldDetectionPrompt(recipients);
|
||||
|
||||
const detectedFields = await safeGenerateObject(
|
||||
async () =>
|
||||
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,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
{
|
||||
operation: 'detect form fields',
|
||||
pageNumber,
|
||||
},
|
||||
);
|
||||
|
||||
return validateAndEnrichFields(detectedFields, recipients, pageNumber);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate recipient IDs and add page numbers to detected fields.
|
||||
*/
|
||||
function validateAndEnrichFields(
|
||||
detectedFields: Array<Omit<TDetectFormFieldsResponse[0], 'pageNumber'>>,
|
||||
recipients: FieldDetectionRecipient[],
|
||||
pageNumber: number,
|
||||
): TDetectFormFieldsResponse {
|
||||
const recipientIds = new Set(recipients.map((r) => r.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 detectedFields.map((field) => {
|
||||
const validatedRecipientId = validateRecipientId(
|
||||
field.recipientId,
|
||||
recipientIds,
|
||||
fallbackRecipientId,
|
||||
{ fieldLabel: field.label },
|
||||
);
|
||||
|
||||
return {
|
||||
...field,
|
||||
recipientId: validatedRecipientId,
|
||||
pageNumber,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
import { Hono } from 'hono';
|
||||
|
||||
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { renderPdfToImage } from '@documenso/lib/server-only/pdf/render-pdf-to-image';
|
||||
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
import { logger } from '@documenso/lib/utils/logger';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import type { HonoEnv } from '../../router';
|
||||
import { authorizeDocumentAccess } from './authorization';
|
||||
import { saveDebugVisualization } from './debug-visualizer';
|
||||
import type { FieldDetectionRecipient } from './field-detection';
|
||||
import { runFormFieldDetection } from './field-detection';
|
||||
import { MAX_PAGES_FOR_RECIPIENT_ANALYSIS, analyzePageForRecipients } from './recipient-detection';
|
||||
import type { TAnalyzeRecipientsResponse, TDetectFormFieldsResponse } from './types';
|
||||
import {
|
||||
ZAnalyzeRecipientsRequestSchema,
|
||||
ZDetectFormFieldsRequestSchema,
|
||||
ZDetectFormFieldsResponseSchema,
|
||||
} from './types';
|
||||
import { processPageBatch, sortRecipientsForDetection } from './utils';
|
||||
|
||||
/**
|
||||
* Validates the user has a verified email for AI features.
|
||||
*/
|
||||
async function validateUserForAI(request: Request): Promise<{ userId: number }> {
|
||||
const { user } = await getSession(request);
|
||||
|
||||
if (!user.emailVerified) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'Email verification required',
|
||||
userMessage: 'Please verify your email to use AI features',
|
||||
});
|
||||
}
|
||||
|
||||
return { userId: user.id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches recipients for an envelope and validates they exist.
|
||||
*/
|
||||
async function getEnvelopeRecipients(envelopeId: string): Promise<FieldDetectionRecipient[]> {
|
||||
const recipients = await prisma.recipient.findMany({
|
||||
where: { envelopeId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
role: true,
|
||||
signingOrder: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (recipients.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.',
|
||||
});
|
||||
}
|
||||
|
||||
return sortRecipientsForDetection(recipients);
|
||||
}
|
||||
|
||||
export const aiRoute = new Hono<HonoEnv>()
|
||||
.post('/detect-fields', async (c) => {
|
||||
try {
|
||||
const { userId } = await validateUserForAI(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, userId);
|
||||
const detectionRecipients = await getEnvelopeRecipients(envelopeId);
|
||||
|
||||
const pdfBytes = await getFileServerSide({
|
||||
type: documentData.type,
|
||||
data: documentData.initialData || documentData.data,
|
||||
});
|
||||
|
||||
const renderedPages = await renderPdfToImage(pdfBytes);
|
||||
|
||||
const { results: pageResults } = await processPageBatch(
|
||||
renderedPages,
|
||||
async (page) => runFormFieldDetection(page.image, page.pageNumber, detectionRecipients),
|
||||
{
|
||||
itemName: 'page',
|
||||
getItemIdentifier: (_, index) => renderedPages[index]?.pageNumber ?? index + 1,
|
||||
errorMessage: 'We could not detect fields on some pages. Please try again.',
|
||||
},
|
||||
);
|
||||
|
||||
const detectedFields = pageResults.flat();
|
||||
|
||||
if (env('NEXT_PUBLIC_AI_DEBUG_PREVIEW') === 'true') {
|
||||
await saveDebugVisualization(renderedPages, detectedFields);
|
||||
}
|
||||
|
||||
const validatedResponse = ZDetectFormFieldsResponseSchema.parse(detectedFields);
|
||||
|
||||
return c.json<TDetectFormFieldsResponse>(validatedResponse);
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.error('Failed to detect form fields from PDF:', {
|
||||
error: error instanceof Error ? error.message : String(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 { userId } = await validateUserForAI(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, userId);
|
||||
|
||||
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: pageResults } = await processPageBatch(
|
||||
pagesToAnalyze,
|
||||
async (page) => analyzePageForRecipients(page),
|
||||
{
|
||||
itemName: 'page',
|
||||
getItemIdentifier: (page) => page.pageNumber,
|
||||
errorMessage: 'We could not analyze recipients on some pages. Please try again.',
|
||||
},
|
||||
);
|
||||
|
||||
const allRecipients = pageResults.flat();
|
||||
|
||||
return c.json<TAnalyzeRecipientsResponse>(allRecipients);
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.error('Failed to analyze recipients from PDF:', {
|
||||
error: error instanceof Error ? error.message : String(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.',
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
// Re-export all types from the module for convenient importing
|
||||
export type {
|
||||
TAnalyzeRecipientsRequest,
|
||||
TAnalyzeRecipientsResponse,
|
||||
TDetectedFormField,
|
||||
TDetectedRecipient,
|
||||
TDetectFormFieldsRequest,
|
||||
TDetectFormFieldsResponse,
|
||||
TGenerateTextRequest,
|
||||
TGenerateTextResponse,
|
||||
TRecipientRole,
|
||||
} from './types';
|
||||
|
||||
export type { FieldDetectionRecipient } from './field-detection';
|
||||
export type { PageWithImage } from './recipient-detection';
|
||||
export type { RenderedPage } from './debug-visualizer';
|
||||
@@ -1,121 +0,0 @@
|
||||
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"`;
|
||||
@@ -1,72 +0,0 @@
|
||||
import { generateObject } from 'ai';
|
||||
|
||||
import { resizeAndCompressImage } from '@documenso/lib/server-only/image/resize-and-compress-image';
|
||||
import { resolveRecipientEmail } from '@documenso/lib/utils/recipients';
|
||||
|
||||
import { ANALYZE_RECIPIENTS_PROMPT } from './prompts';
|
||||
import type { TDetectedRecipient } from './types';
|
||||
import { ZDetectedRecipientLLMSchema } from './types';
|
||||
import { safeGenerateObject } from './utils';
|
||||
|
||||
// Limit recipient detection to first 3 pages for performance and cost efficiency
|
||||
export const MAX_PAGES_FOR_RECIPIENT_ANALYSIS = 3;
|
||||
|
||||
export type PageWithImage = {
|
||||
image: Buffer;
|
||||
pageNumber: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Analyze a single page for recipient information.
|
||||
*/
|
||||
export async function analyzePageForRecipients(page: PageWithImage): Promise<TDetectedRecipient[]> {
|
||||
const compressedImageBuffer = await resizeAndCompressImage(page.image);
|
||||
const base64Image = compressedImageBuffer.toString('base64');
|
||||
|
||||
const recipients = await safeGenerateObject(
|
||||
async () =>
|
||||
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,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
{
|
||||
operation: 'analyze recipients',
|
||||
pageNumber: page.pageNumber,
|
||||
},
|
||||
);
|
||||
|
||||
return normalizeRecipients(recipients);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize recipient data by resolving emails and ensuring consistent format.
|
||||
*/
|
||||
function normalizeRecipients(
|
||||
recipients: Array<{
|
||||
name: string;
|
||||
email?: string;
|
||||
role: 'SIGNER' | 'APPROVER' | 'CC';
|
||||
signingOrder?: number;
|
||||
}>,
|
||||
): TDetectedRecipient[] {
|
||||
return recipients.map((recipient) => ({
|
||||
...recipient,
|
||||
email: resolveRecipientEmail(recipient.email),
|
||||
}));
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
type TDetectedFormField,
|
||||
ZDetectedFormFieldSchema,
|
||||
} from '@documenso/lib/types/document-analysis';
|
||||
|
||||
export { ZDetectedFormFieldSchema };
|
||||
|
||||
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 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 ZRecipientRoleEnum = z.enum(['SIGNER', 'APPROVER', 'CC']);
|
||||
|
||||
const recipientFieldShape = {
|
||||
name: z.string().describe('Full name of the recipient'),
|
||||
role: ZRecipientRoleEnum.describe('Recipient role based on document context'),
|
||||
signingOrder: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.describe('Sequential signing order if document indicates ordering'),
|
||||
} as const;
|
||||
|
||||
function createRecipientSchema<TSchema extends z.ZodTypeAny>(emailSchema: TSchema) {
|
||||
return 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>;
|
||||
export type TRecipientRole = z.infer<typeof ZRecipientRoleEnum>;
|
||||
@@ -1,164 +0,0 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { logger } from '@documenso/lib/utils/logger';
|
||||
|
||||
/**
|
||||
* Process an array of items in parallel and handle failures gracefully.
|
||||
* Returns successful results and reports failed items.
|
||||
*/
|
||||
export async function processPageBatch<TInput, TOutput>(
|
||||
items: TInput[],
|
||||
processor: (item: TInput, index: number) => Promise<TOutput>,
|
||||
context: {
|
||||
itemName: string; // e.g., "page", "recipient"
|
||||
getItemIdentifier: (item: TInput, index: number) => number | string; // e.g., pageNumber
|
||||
errorMessage: string; // User-facing error message
|
||||
},
|
||||
): Promise<{
|
||||
results: TOutput[];
|
||||
failedItems: Array<number | string>;
|
||||
}> {
|
||||
const settledResults = await Promise.allSettled(
|
||||
items.map(async (item, index) => processor(item, index)),
|
||||
);
|
||||
|
||||
const results: TOutput[] = [];
|
||||
const failedItems: Array<number | string> = [];
|
||||
|
||||
for (const [index, result] of settledResults.entries()) {
|
||||
if (result.status === 'fulfilled') {
|
||||
results.push(result.value);
|
||||
} else {
|
||||
const identifier = context.getItemIdentifier(items[index]!, index);
|
||||
logger.error(`Failed to process ${context.itemName} ${identifier}:`, {
|
||||
error: result.reason,
|
||||
identifier,
|
||||
});
|
||||
failedItems.push(identifier);
|
||||
}
|
||||
}
|
||||
|
||||
if (failedItems.length > 0) {
|
||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
||||
message: `Failed to process ${context.itemName}s: ${failedItems.join(', ')}`,
|
||||
userMessage: context.errorMessage,
|
||||
});
|
||||
}
|
||||
|
||||
return { results, failedItems: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely execute an LLM generation with proper error handling and logging.
|
||||
*/
|
||||
export async function safeGenerateObject<T>(
|
||||
generatorFn: () => Promise<{ object: T }>,
|
||||
context: {
|
||||
operation: string; // e.g., "detect form fields", "analyze recipients"
|
||||
pageNumber?: number;
|
||||
},
|
||||
): Promise<T> {
|
||||
try {
|
||||
const result = await generatorFn();
|
||||
return result.object;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
const pageContext = context.pageNumber ? ` on page ${context.pageNumber}` : '';
|
||||
|
||||
logger.error(`Failed to ${context.operation}${pageContext}:`, {
|
||||
error: errorMessage,
|
||||
pageNumber: context.pageNumber,
|
||||
});
|
||||
|
||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
||||
message: `AI generation failed for ${context.operation}: ${errorMessage}`,
|
||||
userMessage: `Unable to ${context.operation}. Please try again.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort recipients by role priority and signing order for consistent field assignment.
|
||||
*/
|
||||
export function sortRecipientsForDetection<
|
||||
T extends { role: string; signingOrder: number | null; id: number },
|
||||
>(recipients: T[]): T[] {
|
||||
const ROLE_PRIORITY: Record<string, number> = {
|
||||
SIGNER: 0,
|
||||
APPROVER: 1,
|
||||
CC: 2,
|
||||
};
|
||||
|
||||
return recipients.slice().sort((a, b) => {
|
||||
// 1. Sort by role priority
|
||||
const roleComparison = (ROLE_PRIORITY[a.role] ?? 3) - (ROLE_PRIORITY[b.role] ?? 3);
|
||||
if (roleComparison !== 0) {
|
||||
return roleComparison;
|
||||
}
|
||||
|
||||
// 2. Sort by signing order (null values last)
|
||||
const aOrder = a.signingOrder ?? Number.MAX_SAFE_INTEGER;
|
||||
const bOrder = b.signingOrder ?? Number.MAX_SAFE_INTEGER;
|
||||
if (aOrder !== bOrder) {
|
||||
return aOrder - bOrder;
|
||||
}
|
||||
|
||||
// 3. Sort by ID as final tiebreaker
|
||||
return a.id - b.id;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a recipient directory string for LLM context.
|
||||
*/
|
||||
export function buildRecipientDirectory(
|
||||
recipients: Array<{
|
||||
id: number;
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
role: string;
|
||||
signingOrder: number | null;
|
||||
}>,
|
||||
): string {
|
||||
return 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');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and correct recipient IDs to ensure they match available recipients.
|
||||
*/
|
||||
export function validateRecipientId(
|
||||
fieldRecipientId: number,
|
||||
availableRecipientIds: Set<number>,
|
||||
fallbackRecipientId: number,
|
||||
context?: { fieldLabel?: string },
|
||||
): number {
|
||||
if (availableRecipientIds.has(fieldRecipientId)) {
|
||||
return fieldRecipientId;
|
||||
}
|
||||
|
||||
logger.error('AI returned invalid recipientId for detected field', {
|
||||
invalidRecipientId: fieldRecipientId,
|
||||
fieldLabel: context?.fieldLabel,
|
||||
availableRecipientIds: Array.from(availableRecipientIds),
|
||||
});
|
||||
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: `AI assigned field "${context?.fieldLabel || 'Unknown'}" to invalid recipient ID ${fieldRecipientId}`,
|
||||
userMessage:
|
||||
'We detected fields assigned to a recipient that does not exist. Please try again.',
|
||||
});
|
||||
}
|
||||
@@ -12,10 +12,11 @@ import { API_V2_BETA_URL, API_V2_URL } from '@documenso/lib/constants/app';
|
||||
import { jobsClient } from '@documenso/lib/jobs/client';
|
||||
import { TelemetryClient } from '@documenso/lib/server-only/telemetry/telemetry-client';
|
||||
import { getIpAddress } from '@documenso/lib/universal/get-ip-address';
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
import { logger } from '@documenso/lib/utils/logger';
|
||||
import { openApiDocument } from '@documenso/trpc/server/open-api';
|
||||
|
||||
import { aiRoute } from './api/document-analysis/index';
|
||||
import { aiRoute } from './api/ai/route';
|
||||
import { downloadRoute } from './api/download/download';
|
||||
import { filesRoute } from './api/files/files';
|
||||
import { type AppContext, appContext } from './context';
|
||||
@@ -51,6 +52,21 @@ const rateLimitMiddleware = rateLimiter({
|
||||
},
|
||||
});
|
||||
|
||||
const aiRateLimitMiddleware = rateLimiter({
|
||||
windowMs: 60 * 1000, // 1 minute
|
||||
limit: 3, // 3 requests per window
|
||||
keyGenerator: (c) => {
|
||||
try {
|
||||
return getIpAddress(c.req.raw);
|
||||
} catch (error) {
|
||||
return 'unknown';
|
||||
}
|
||||
},
|
||||
message: {
|
||||
error: 'Too many requests, please try again later.',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Attach session and context to requests.
|
||||
*/
|
||||
@@ -87,6 +103,7 @@ app.route('/api/auth', auth);
|
||||
app.route('/api/files', filesRoute);
|
||||
|
||||
// AI route.
|
||||
app.use('/api/ai/*', aiRateLimitMiddleware);
|
||||
app.route('/api/ai', aiRoute);
|
||||
|
||||
// API servers.
|
||||
@@ -119,6 +136,8 @@ app.use(`${API_V2_BETA_URL}/*`, async (c) =>
|
||||
|
||||
// Start telemetry client for anonymous usage tracking.
|
||||
// Can be disabled by setting DOCUMENSO_DISABLE_TELEMETRY=true
|
||||
void TelemetryClient.start();
|
||||
if (env('NODE_ENV') !== 'development') {
|
||||
void TelemetryClient.start();
|
||||
}
|
||||
|
||||
export default app;
|
||||
|
||||
@@ -51,6 +51,7 @@ export default defineConfig({
|
||||
ssr: {
|
||||
noExternal: ['react-dropzone', 'plausible-tracker'],
|
||||
external: [
|
||||
'@napi-rs/canvas',
|
||||
'@node-rs/bcrypt',
|
||||
'@prisma/client',
|
||||
'@documenso/tailwind-config',
|
||||
@@ -64,6 +65,7 @@ export default defineConfig({
|
||||
include: ['prop-types', 'file-selector', 'attr-accept'],
|
||||
exclude: [
|
||||
'node_modules',
|
||||
'@napi-rs/canvas',
|
||||
'@node-rs/bcrypt',
|
||||
'@documenso/pdf-sign',
|
||||
'sharp',
|
||||
@@ -94,6 +96,7 @@ export default defineConfig({
|
||||
build: {
|
||||
rollupOptions: {
|
||||
external: [
|
||||
'@napi-rs/canvas',
|
||||
'@node-rs/bcrypt',
|
||||
'@documenso/pdf-sign',
|
||||
'@aws-sdk/cloudfront-signer',
|
||||
|
||||
@@ -70,6 +70,7 @@ COPY --from=builder /app/out/json/ .
|
||||
COPY --from=builder /app/out/package-lock.json ./package-lock.json
|
||||
|
||||
COPY --from=builder /app/lingui.config.ts ./lingui.config.ts
|
||||
COPY --from=builder /app/patches ./patches
|
||||
|
||||
RUN npm ci
|
||||
|
||||
@@ -108,6 +109,8 @@ WORKDIR /app
|
||||
COPY --from=builder --chown=nodejs:nodejs /app/out/json/ .
|
||||
# Copy the tailwind config files across
|
||||
COPY --from=builder --chown=nodejs:nodejs /app/out/full/packages/tailwind-config ./packages/tailwind-config
|
||||
# Copy the patches across
|
||||
COPY --from=builder --chown=nodejs:nodejs /app/patches ./patches
|
||||
|
||||
RUN npm ci --only=production
|
||||
|
||||
|
||||
Generated
+239
-725
File diff suppressed because it is too large
Load Diff
+6
-8
@@ -5,7 +5,7 @@
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
],
|
||||
"version": "2.1.0",
|
||||
"version": "2.2.3",
|
||||
"scripts": {
|
||||
"postinstall": "patch-package",
|
||||
"build": "turbo run build",
|
||||
@@ -61,12 +61,10 @@
|
||||
"dotenv-cli": "^11.0.0",
|
||||
"eslint": "^8.57.0",
|
||||
"husky": "^9.1.7",
|
||||
"kysely": "0.28.8",
|
||||
"lint-staged": "^16.2.7",
|
||||
"nanoid": "^5.1.6",
|
||||
"nodemailer": "^7.0.10",
|
||||
"patch-package": "^8.0.1",
|
||||
"pdfjs-dist": "5.4.296",
|
||||
"pdfjs-dist": "5.4.449",
|
||||
"pino": "^9.14.0",
|
||||
"pino-pretty": "^13.1.2",
|
||||
"playwright": "1.56.1",
|
||||
@@ -91,18 +89,18 @@
|
||||
"@documenso/prisma": "*",
|
||||
"@lingui/conf": "^5.6.0",
|
||||
"@lingui/core": "^5.6.0",
|
||||
"ai": "^5.0.82",
|
||||
"ai": "^5.0.104",
|
||||
"inngest-cli": "^1.13.7",
|
||||
"luxon": "^3.7.2",
|
||||
"mupdf": "^1.0.0",
|
||||
"patch-package": "^8.0.1",
|
||||
"posthog-node": "4.18.0",
|
||||
"react": "^18",
|
||||
"typescript": "5.6.2",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"overrides": {
|
||||
"pdfjs-dist": "5.4.296",
|
||||
"pdfjs-dist": "5.4.449",
|
||||
"typescript": "5.6.2",
|
||||
"zod": "^3.25.76"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"devDependencies": {
|
||||
"@documenso/lib": "*",
|
||||
"@documenso/prisma": "*",
|
||||
"@napi-rs/canvas": "^0.1.82",
|
||||
"@napi-rs/canvas": "^0.1.83",
|
||||
"@playwright/test": "1.56.1",
|
||||
"@types/node": "^20",
|
||||
"@types/pngjs": "^6.0.5",
|
||||
|
||||
@@ -132,7 +132,12 @@ export const EnvelopeEditorProvider = ({
|
||||
});
|
||||
|
||||
const envelopeFieldSetMutationQuery = trpc.envelope.field.set.useMutation({
|
||||
onSuccess: () => {
|
||||
onSuccess: ({ data: fields }) => {
|
||||
setEnvelope((prev) => ({
|
||||
...prev,
|
||||
fields,
|
||||
}));
|
||||
|
||||
setAutosaveError(false);
|
||||
},
|
||||
onError: (err) => {
|
||||
@@ -154,8 +159,18 @@ export const EnvelopeEditorProvider = ({
|
||||
setEnvelope((prev) => ({
|
||||
...prev,
|
||||
recipients,
|
||||
fields: prev.fields.filter((field) =>
|
||||
recipients.some((recipient) => recipient.id === field.recipientId),
|
||||
),
|
||||
}));
|
||||
|
||||
// Reset the local fields to ensure deleted recipient fields are removed.
|
||||
editorFields.resetForm(
|
||||
envelope.fields.filter((field) =>
|
||||
recipients.some((recipient) => recipient.id === field.recipientId),
|
||||
),
|
||||
);
|
||||
|
||||
setAutosaveError(false);
|
||||
},
|
||||
onError: (err) => {
|
||||
@@ -265,7 +280,7 @@ export const EnvelopeEditorProvider = ({
|
||||
);
|
||||
|
||||
/**
|
||||
* Fetch and sycn the envelope back into the editor.
|
||||
* Fetch and sync the envelope back into the editor.
|
||||
*
|
||||
* Overrides everything.
|
||||
*/
|
||||
|
||||
@@ -18,3 +18,6 @@ export const SUPPORT_EMAIL = env('NEXT_PUBLIC_SUPPORT_EMAIL') ?? 'support@docume
|
||||
|
||||
export const USE_INTERNAL_URL_BROWSERLESS = () =>
|
||||
env('NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS') === 'true';
|
||||
|
||||
export const IS_AI_FEATURES_CONFIGURED = () =>
|
||||
!!env('GOOGLE_VERTEX_PROJECT_ID') && !!env('GOOGLE_VERTEX_API_KEY');
|
||||
|
||||
@@ -6,6 +6,7 @@ export const SUPPORTED_LANGUAGE_CODES = [
|
||||
'fr',
|
||||
'es',
|
||||
'it',
|
||||
'nl',
|
||||
'pl',
|
||||
'pt-BR',
|
||||
'ja',
|
||||
@@ -61,6 +62,10 @@ export const SUPPORTED_LANGUAGES: Record<string, SupportedLanguage> = {
|
||||
full: 'Italian',
|
||||
short: 'it',
|
||||
},
|
||||
nl: {
|
||||
short: 'nl',
|
||||
full: 'Dutch',
|
||||
},
|
||||
pl: {
|
||||
short: 'pl',
|
||||
full: 'Polish',
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"clean": "rimraf node_modules"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/google-vertex": "3.0.81",
|
||||
"@aws-sdk/client-s3": "^3.936.0",
|
||||
"@aws-sdk/client-sesv2": "^3.936.0",
|
||||
"@aws-sdk/cloudfront-signer": "^3.935.0",
|
||||
@@ -27,6 +28,7 @@
|
||||
"@lingui/core": "^5.6.0",
|
||||
"@lingui/macro": "^5.6.0",
|
||||
"@lingui/react": "^5.6.0",
|
||||
"@napi-rs/canvas": "^0.1.83",
|
||||
"@noble/ciphers": "0.6.0",
|
||||
"@noble/hashes": "1.8.0",
|
||||
"@node-rs/bcrypt": "^1.10.7",
|
||||
@@ -35,6 +37,7 @@
|
||||
"@sindresorhus/slugify": "^3.0.0",
|
||||
"@team-plain/typescript-sdk": "^5.11.0",
|
||||
"@vvo/tzdb": "^6.196.0",
|
||||
"ai": "^5.0.104",
|
||||
"csv-parse": "^6.1.0",
|
||||
"inngest": "^3.45.1",
|
||||
"jose": "^6.1.2",
|
||||
@@ -43,7 +46,7 @@
|
||||
"luxon": "^3.7.2",
|
||||
"nanoid": "^5.1.6",
|
||||
"oslo": "^0.17.0",
|
||||
"pdfjs-dist": "3.11.174",
|
||||
"p-map": "^7.0.4",
|
||||
"pg": "^8.16.3",
|
||||
"pino": "^9.14.0",
|
||||
"pino-pretty": "^13.1.2",
|
||||
@@ -64,4 +67,4 @@
|
||||
"@types/luxon": "^3.7.1",
|
||||
"@types/pg": "^8.15.6"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { DetectedField } from './schema';
|
||||
import type { NormalizedField, RecipientContext } from './types';
|
||||
|
||||
/**
|
||||
* Build a message providing recipient context to the AI.
|
||||
*/
|
||||
export const buildRecipientContextMessage = (recipients: RecipientContext[]) => {
|
||||
if (recipients.length === 0) {
|
||||
return 'No recipients have been specified for this document. Leave recipientKey empty for all fields.';
|
||||
}
|
||||
|
||||
const recipientList = recipients.map((r) => `- ${formatRecipientKey(r)}`).join('\n');
|
||||
|
||||
return `The following recipients will sign/fill this document. Use their recipientKey when assigning fields:
|
||||
|
||||
${recipientList}
|
||||
|
||||
When you detect a field that should be filled by a specific recipient (based on nearby labels like "Tenant Signature", "Landlord", "Buyer", etc.), set the recipientKey to match one of the above. If no recipient can be determined, leave recipientKey empty.`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Format recipient key as id|name|email for AI context.
|
||||
*/
|
||||
export const formatRecipientKey = (recipient: RecipientContext) => {
|
||||
return `${recipient.id}|${recipient.name}|${recipient.email}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse recipientKey (format: id|name|email) and find matching recipient.
|
||||
*
|
||||
* Matching logic:
|
||||
* 1. Match on id === id
|
||||
* 2. OR match on email && name === email && name
|
||||
* 3. If no match or empty key, use first recipient
|
||||
* 4. If no recipients, return null (caller creates blank recipient)
|
||||
*/
|
||||
export const resolveRecipientFromKey = (recipientKey: string, recipients: RecipientContext[]) => {
|
||||
if (recipients.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Empty key defaults to first recipient
|
||||
if (!recipientKey) {
|
||||
return recipients[0];
|
||||
}
|
||||
|
||||
// Parse the key format: id|name|email
|
||||
const [idStr, name, email] = recipientKey.split('|');
|
||||
|
||||
const id = Number(idStr);
|
||||
|
||||
// Try to match by ID first
|
||||
if (!Number.isNaN(id)) {
|
||||
const matchById = recipients.find((r) => r.id === id);
|
||||
|
||||
if (matchById) {
|
||||
return matchById;
|
||||
}
|
||||
}
|
||||
|
||||
// Try to match by email AND name
|
||||
if (email && name) {
|
||||
const matchByEmailAndName = recipients.find((r) => r.email === email && r.name === name);
|
||||
|
||||
if (matchByEmailAndName) {
|
||||
return matchByEmailAndName;
|
||||
}
|
||||
}
|
||||
|
||||
// No match found, default to first recipient
|
||||
return recipients[0];
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert AI's 0-1000 bounding box to our 0-100 percentage format.
|
||||
*/
|
||||
export const normalizeDetectedField = (field: DetectedField): NormalizedField => {
|
||||
const { box2d } = field;
|
||||
|
||||
const [yMin, xMin, yMax, xMax] = box2d;
|
||||
|
||||
return {
|
||||
type: field.type,
|
||||
recipientKey: field.recipientKey,
|
||||
positionX: xMin / 10,
|
||||
positionY: yMin / 10,
|
||||
width: (xMax - xMin) / 10,
|
||||
height: (yMax - yMin) / 10,
|
||||
confidence: field.confidence,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,323 @@
|
||||
import { createCanvas, loadImage } from '@napi-rs/canvas';
|
||||
import { DocumentStatus, type Field, RecipientRole } from '@prisma/client';
|
||||
import { generateObject } from 'ai';
|
||||
import pMap from 'p-map';
|
||||
import sharp from 'sharp';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../../../errors/app-error';
|
||||
import { getFileServerSide } from '../../../../universal/upload/get-file.server';
|
||||
import { getEnvelopeById } from '../../../envelope/get-envelope-by-id';
|
||||
import { createEnvelopeRecipients } from '../../../recipient/create-envelope-recipients';
|
||||
import { vertex } from '../../google';
|
||||
import { pdfToImages } from '../../pdf-to-images';
|
||||
import {
|
||||
buildRecipientContextMessage,
|
||||
normalizeDetectedField,
|
||||
resolveRecipientFromKey,
|
||||
} from './helpers';
|
||||
import { SYSTEM_PROMPT } from './prompt';
|
||||
import { ZSubmitDetectedFieldsInputSchema } from './schema';
|
||||
import type {
|
||||
NormalizedFieldWithContext,
|
||||
NormalizedFieldWithPage,
|
||||
RecipientContext,
|
||||
} from './types';
|
||||
|
||||
export type DetectFieldsFromEnvelopeOptions = {
|
||||
context?: string;
|
||||
envelopeId: string;
|
||||
userId: number;
|
||||
teamId: number;
|
||||
onProgress?: (progress: DetectFieldsProgress) => void;
|
||||
};
|
||||
|
||||
export const detectFieldsFromEnvelope = async ({
|
||||
context,
|
||||
envelopeId,
|
||||
userId,
|
||||
teamId,
|
||||
onProgress,
|
||||
}: DetectFieldsFromEnvelopeOptions) => {
|
||||
const envelope = await getEnvelopeById({
|
||||
id: {
|
||||
type: 'envelopeId',
|
||||
id: envelopeId,
|
||||
},
|
||||
userId,
|
||||
teamId,
|
||||
type: null,
|
||||
});
|
||||
|
||||
if (envelope.status !== DocumentStatus.DRAFT) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Cannot detect fields for a non-draft envelope',
|
||||
});
|
||||
}
|
||||
|
||||
// Extract recipients for field assignment context
|
||||
const recipients: RecipientContext[] = envelope.recipients.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
email: r.email,
|
||||
}));
|
||||
|
||||
const allFields: NormalizedFieldWithContext[] = [];
|
||||
|
||||
for (const item of envelope.envelopeItems) {
|
||||
const existingFields = await prisma.field.findMany({
|
||||
where: {
|
||||
envelopeItemId: item.id,
|
||||
},
|
||||
});
|
||||
|
||||
const pdfBytes = await getFileServerSide(item.documentData);
|
||||
const fields = await detectFieldsFromPdf({
|
||||
pdfBytes,
|
||||
existingFields,
|
||||
recipients,
|
||||
context,
|
||||
onProgress,
|
||||
});
|
||||
|
||||
// Resolve recipientKey to actual recipient and add context
|
||||
const fieldsWithContext = await Promise.all(
|
||||
fields.map(async (field) => {
|
||||
const { recipientKey, ...fieldWithoutKey } = field;
|
||||
|
||||
let resolvedRecipient = resolveRecipientFromKey(recipientKey, recipients);
|
||||
|
||||
// If no recipients exist, create a blank recipient
|
||||
if (!resolvedRecipient) {
|
||||
const { recipients: createdRecipients } = await createEnvelopeRecipients({
|
||||
id: {
|
||||
id: envelope.id,
|
||||
type: 'envelopeId',
|
||||
},
|
||||
recipients: [
|
||||
{
|
||||
name: '',
|
||||
email: '',
|
||||
role: RecipientRole.SIGNER,
|
||||
},
|
||||
],
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
resolvedRecipient = createdRecipients[0];
|
||||
}
|
||||
|
||||
return {
|
||||
...fieldWithoutKey,
|
||||
envelopeItemId: item.id,
|
||||
recipientId: resolvedRecipient.id,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
allFields.push(...fieldsWithContext);
|
||||
}
|
||||
|
||||
return allFields;
|
||||
};
|
||||
|
||||
export type DetectFieldsProgress = {
|
||||
pagesProcessed: number;
|
||||
totalPages: number;
|
||||
fieldsDetected: number;
|
||||
};
|
||||
|
||||
export type DetectFieldsFromPdfOptions = {
|
||||
pdfBytes: Uint8Array;
|
||||
recipients?: RecipientContext[];
|
||||
existingFields?: Field[];
|
||||
context?: string;
|
||||
onProgress?: (progress: DetectFieldsProgress) => void;
|
||||
};
|
||||
|
||||
export const detectFieldsFromPdf = async ({
|
||||
pdfBytes,
|
||||
recipients = [],
|
||||
existingFields = [],
|
||||
context,
|
||||
onProgress,
|
||||
}: DetectFieldsFromPdfOptions) => {
|
||||
const pageImages = await pdfToImages(pdfBytes);
|
||||
|
||||
if (pageImages.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let pagesProcessed = 0;
|
||||
let totalFieldsDetected = 0;
|
||||
|
||||
const results = await pMap(
|
||||
pageImages,
|
||||
async (page) => {
|
||||
// Get existing fields for this page
|
||||
const fieldsOnPage = existingFields.filter((f) => f.page === page.pageNumber);
|
||||
|
||||
// Mask existing fields on the image
|
||||
const maskedImage = await maskFieldsOnImage({
|
||||
image: page.image,
|
||||
width: page.width,
|
||||
height: page.height,
|
||||
fields: fieldsOnPage,
|
||||
});
|
||||
|
||||
const rawFields = await detectFieldsFromPage({
|
||||
image: maskedImage,
|
||||
pageNumber: page.pageNumber,
|
||||
recipients,
|
||||
context,
|
||||
});
|
||||
|
||||
// Convert bounding boxes to normalized positions and add page number
|
||||
const normalizedFields = rawFields.map(
|
||||
(field): NormalizedFieldWithPage => ({
|
||||
...normalizeDetectedField(field),
|
||||
pageNumber: page.pageNumber,
|
||||
}),
|
||||
);
|
||||
|
||||
// Update progress
|
||||
pagesProcessed += 1;
|
||||
totalFieldsDetected += normalizedFields.length;
|
||||
|
||||
onProgress?.({
|
||||
pagesProcessed,
|
||||
totalPages: pageImages.length,
|
||||
fieldsDetected: totalFieldsDetected,
|
||||
});
|
||||
|
||||
return normalizedFields;
|
||||
},
|
||||
{ concurrency: 5 },
|
||||
);
|
||||
|
||||
return results.flat();
|
||||
};
|
||||
|
||||
type MaskFieldsOnImageOptions = {
|
||||
image: Buffer;
|
||||
width: number;
|
||||
height: number;
|
||||
fields: Field[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Draw black rectangles over existing fields to prevent re-detection.
|
||||
*/
|
||||
const maskFieldsOnImage = async ({ image, width, height, fields }: MaskFieldsOnImageOptions) => {
|
||||
if (fields.length === 0) {
|
||||
return image;
|
||||
}
|
||||
|
||||
const img = await loadImage(image);
|
||||
const canvas = createCanvas(width, height);
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// Draw the original image
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
|
||||
// Draw black rectangles over existing fields
|
||||
ctx.fillStyle = '#000000';
|
||||
|
||||
for (const field of fields) {
|
||||
// field positions and width,height are on a 0-100 percentage scale
|
||||
const x = (field.positionX.toNumber() / 100) * width;
|
||||
const y = (field.positionY.toNumber() / 100) * height;
|
||||
const w = (field.width.toNumber() / 100) * width;
|
||||
const h = (field.height.toNumber() / 100) * height;
|
||||
|
||||
ctx.fillRect(x, y, w, h);
|
||||
}
|
||||
|
||||
return canvas.encode('jpeg');
|
||||
};
|
||||
|
||||
const TARGET_SIZE = 1000;
|
||||
|
||||
type ResizeImageOptions = {
|
||||
image: Buffer;
|
||||
size?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resize image to 1000x1000 using fill strategy.
|
||||
* Scales to cover the target area and crops any overflow.
|
||||
*/
|
||||
const resizeImageToSquare = async ({ image, size = TARGET_SIZE }: ResizeImageOptions) => {
|
||||
return await sharp(image).resize(size, size, { fit: 'fill' }).toBuffer();
|
||||
};
|
||||
|
||||
type DetectFieldsFromPageOptions = {
|
||||
image: Buffer;
|
||||
pageNumber: number;
|
||||
recipients: RecipientContext[];
|
||||
context?: string;
|
||||
};
|
||||
|
||||
const detectFieldsFromPage = async ({
|
||||
image,
|
||||
pageNumber,
|
||||
recipients,
|
||||
context,
|
||||
}: DetectFieldsFromPageOptions) => {
|
||||
// Resize to 1000x1000 for consistent coordinate mapping
|
||||
const resizedImage = await resizeImageToSquare({ image });
|
||||
|
||||
// Build messages array
|
||||
const messages: Parameters<typeof generateObject>[0]['messages'] = [
|
||||
{
|
||||
role: 'user',
|
||||
content: buildRecipientContextMessage(recipients),
|
||||
},
|
||||
];
|
||||
|
||||
// Add user-provided context if available
|
||||
if (context?.trim()) {
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: `Additional context about recipients:\n${context.trim()}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Add the page analysis request with image
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Analyze this document page (page ${pageNumber}) and detect all empty fillable fields. Submit the fields using the tool. Remember: only detect EMPTY fields, exclude labels from bounding boxes, use 0-1000 normalized coordinates, and IGNORE any solid black rectangles (those are existing fields).`,
|
||||
},
|
||||
{
|
||||
type: 'image',
|
||||
image: resizedImage,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await generateObject({
|
||||
model: vertex('gemini-3-pro-preview'),
|
||||
system: SYSTEM_PROMPT,
|
||||
schema: ZSubmitDetectedFieldsInputSchema,
|
||||
messages,
|
||||
temperature: 0.5,
|
||||
providerOptions: {
|
||||
google: {
|
||||
thinkingConfig: {
|
||||
thinkingLevel: 'low',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!result.object) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return result.object.fields ?? [];
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
export const SYSTEM_PROMPT = `You are analyzing a form document image to detect fillable fields for a 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
|
||||
5. IGNORE any black rectangles on the page - these are existing fields that should not be re-detected
|
||||
6. Only return fields that are clearly fillable and not just labels or instructions.
|
||||
|
||||
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
|
||||
- ✓ CORRECT: The blank space to the right of a label: "Email: [ empty box ]" → box ONLY the empty box
|
||||
- ✗ 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
|
||||
- ✗ INCORRECT: Detecting solid black rectangles (these are masked existing fields)
|
||||
|
||||
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'
|
||||
- DATE - Boxes labeled 'Date', 'Date signed', 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', 'ZIP', 'Age', 'Price', '#'
|
||||
- TEXT - Any other empty text input boxes, general input fields, or when field type is uncertain
|
||||
|
||||
DETECTION GUIDELINES:
|
||||
- Read text located near the box (above, to the left, or inside) to infer the field type
|
||||
- Use 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:
|
||||
- Coordinates must capture ONLY the empty fillable space
|
||||
- Once you find the fillable region, LOCK the box to the full boundary (top, bottom, left, right)
|
||||
- If the field is defined by a line or rectangular border, extend coordinates across the entire line/border
|
||||
- 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
|
||||
- The box should never cover only the leftmost few characters of a long field
|
||||
|
||||
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 FOR LINE-BASED FIELDS:
|
||||
When detecting thin horizontal lines for SIGNATURE, INITIALS, NAME, EMAIL, DATE, TEXT, or NUMBER fields:
|
||||
1. Keep yMax (bottom) at the detected line position
|
||||
2. Extend yMin (top) upward into the available whitespace above the line
|
||||
3. Use 60-80% of the clear whitespace above the line for comfortable writing/signing space
|
||||
4. Apply minimum dimensions: height at least 30 units (3% of 1000-scale), width at least 36 units
|
||||
5. Ensure yMin >= 0 (do not go off-page)
|
||||
6. Do NOT apply this expansion to CHECKBOX, RADIO fields - use detected dimensions
|
||||
|
||||
RECIPIENT IDENTIFICATION:
|
||||
- Look for labels near fields indicating who should fill them (e.g., "Tenant Signature", "Landlord", "Buyer")
|
||||
- Use the recipientKey field to indicate which recipient should fill the field
|
||||
- If a field has no clear recipient label, leave recipientKey empty`;
|
||||
@@ -0,0 +1,54 @@
|
||||
import { FieldType } from '@prisma/client';
|
||||
import z from 'zod';
|
||||
|
||||
export const DETECTABLE_FIELD_TYPES = [
|
||||
FieldType.SIGNATURE,
|
||||
FieldType.INITIALS,
|
||||
FieldType.NAME,
|
||||
FieldType.EMAIL,
|
||||
FieldType.DATE,
|
||||
FieldType.TEXT,
|
||||
FieldType.NUMBER,
|
||||
FieldType.RADIO,
|
||||
FieldType.CHECKBOX,
|
||||
] as const;
|
||||
|
||||
export const ZDetectableFieldType = z.enum(DETECTABLE_FIELD_TYPES);
|
||||
|
||||
export const ZConfidenceLevel = z.enum(['low', 'medium-low', 'medium', 'medium-high', 'high']);
|
||||
|
||||
export type TConfidenceLevel = z.infer<typeof ZConfidenceLevel>;
|
||||
|
||||
/**
|
||||
* Schema for a detected field's bounding box.
|
||||
* All values are normalized to a 0-1000 scale relative to the page dimensions.
|
||||
*/
|
||||
const ZBox2DSchema = z.array(z.number().min(0).max(1000)).length(4);
|
||||
|
||||
/**
|
||||
* Schema for a detected field.
|
||||
*/
|
||||
export const ZDetectedFieldSchema = z.object({
|
||||
type: ZDetectableFieldType.describe(
|
||||
`The field type based on nearby labels and visual appearance`,
|
||||
),
|
||||
recipientKey: z
|
||||
.string()
|
||||
.describe(
|
||||
'Recipient identifier from nearby labels (e.g., "Tenant", "Landlord", "Buyer", "Seller"). Empty string if no recipient indicated.',
|
||||
),
|
||||
box2d: ZBox2DSchema.describe(
|
||||
'Box2D [yMin, xMin, yMax, xMax] coordinates of the FILLABLE AREA only (exclude labels).',
|
||||
),
|
||||
confidence: ZConfidenceLevel.describe('The confidence in the detection'),
|
||||
});
|
||||
|
||||
export type DetectedField = z.infer<typeof ZDetectedFieldSchema>;
|
||||
|
||||
export const ZSubmitDetectedFieldsInputSchema = z.object({
|
||||
fields: z
|
||||
.array(ZDetectedFieldSchema)
|
||||
.describe('List of detected EMPTY fillable fields. Exclude pre-filled content and label text.'),
|
||||
});
|
||||
|
||||
export type SubmitDetectedFieldsInput = z.infer<typeof ZSubmitDetectedFieldsInputSchema>;
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Recipient } from '@prisma/client';
|
||||
|
||||
import type { DETECTABLE_FIELD_TYPES, TConfidenceLevel } from './schema';
|
||||
|
||||
export type DetectableFieldType = (typeof DETECTABLE_FIELD_TYPES)[number];
|
||||
|
||||
/**
|
||||
* Normalized field position using 0-100 percentage scale (matching Field model).
|
||||
*/
|
||||
export type NormalizedField = {
|
||||
type: DetectableFieldType;
|
||||
recipientKey: string;
|
||||
positionX: number;
|
||||
positionY: number;
|
||||
width: number;
|
||||
height: number;
|
||||
confidence: TConfidenceLevel;
|
||||
};
|
||||
|
||||
export type RecipientContext = Pick<Recipient, 'id' | 'name' | 'email'>;
|
||||
|
||||
export type NormalizedFieldWithPage = NormalizedField & {
|
||||
pageNumber: number;
|
||||
};
|
||||
|
||||
export type NormalizedFieldWithContext = Omit<NormalizedField, 'recipientKey'> & {
|
||||
pageNumber: number;
|
||||
envelopeItemId: string;
|
||||
recipientId: number;
|
||||
};
|
||||
@@ -0,0 +1,237 @@
|
||||
import { DocumentStatus } from '@prisma/client';
|
||||
import type { ImagePart, ModelMessage } from 'ai';
|
||||
import { generateObject } from 'ai';
|
||||
import { chunk } from 'remeda';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../../../errors/app-error';
|
||||
import { getFileServerSide } from '../../../../universal/upload/get-file.server';
|
||||
import { getEnvelopeById } from '../../../envelope/get-envelope-by-id';
|
||||
import { vertex } from '../../google';
|
||||
import { pdfToImages } from '../../pdf-to-images';
|
||||
import { SYSTEM_PROMPT } from './prompt';
|
||||
import type { TDetectedRecipientSchema } from './schema';
|
||||
import { ZDetectedRecipientsSchema } from './schema';
|
||||
|
||||
const MAX_PAGES_PER_CHUNK = 10;
|
||||
|
||||
const createImageContentParts = (images: Buffer[]) => {
|
||||
return images.map<ImagePart>((image) => ({
|
||||
type: 'image',
|
||||
image,
|
||||
}));
|
||||
};
|
||||
|
||||
export type DetectRecipientsProgress = {
|
||||
pagesProcessed: number;
|
||||
totalPages: number;
|
||||
recipientsDetected: number;
|
||||
};
|
||||
|
||||
export type DetectRecipientsFromEnvelopeOptions = {
|
||||
envelopeId: string;
|
||||
userId: number;
|
||||
teamId: number;
|
||||
onProgress?: (progress: DetectRecipientsProgress) => void;
|
||||
};
|
||||
|
||||
export const detectRecipientsFromEnvelope = async ({
|
||||
envelopeId,
|
||||
userId,
|
||||
teamId,
|
||||
onProgress,
|
||||
}: DetectRecipientsFromEnvelopeOptions) => {
|
||||
const envelope = await getEnvelopeById({
|
||||
id: {
|
||||
type: 'envelopeId',
|
||||
id: envelopeId,
|
||||
},
|
||||
userId,
|
||||
teamId,
|
||||
type: null,
|
||||
});
|
||||
|
||||
if (envelope.status === DocumentStatus.COMPLETED) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Cannot detect recipients for a completed envelope',
|
||||
});
|
||||
}
|
||||
|
||||
let allRecipients: TDetectedRecipientSchema[] = [];
|
||||
|
||||
for (const item of envelope.envelopeItems) {
|
||||
const pdfBytes = await getFileServerSide(item.documentData);
|
||||
const recipients = await detectRecipientsFromPdf({ pdfBytes, onProgress });
|
||||
|
||||
allRecipients = mergeRecipients(allRecipients, recipients);
|
||||
}
|
||||
|
||||
return allRecipients;
|
||||
};
|
||||
|
||||
export type DetectRecipientsFromPdfOptions = {
|
||||
pdfBytes: Uint8Array;
|
||||
onProgress?: (progress: DetectRecipientsProgress) => void;
|
||||
};
|
||||
|
||||
export const detectRecipientsFromPdf = async ({
|
||||
pdfBytes,
|
||||
onProgress,
|
||||
}: DetectRecipientsFromPdfOptions) => {
|
||||
const pageImages = await pdfToImages(pdfBytes);
|
||||
|
||||
if (pageImages.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const images = pageImages.map((p) => p.image);
|
||||
|
||||
return await detectRecipientsFromImages({ images, onProgress });
|
||||
};
|
||||
|
||||
type DetectRecipientsFromImagesOptions = {
|
||||
images: Buffer[];
|
||||
onProgress?: (progress: DetectRecipientsProgress) => void;
|
||||
};
|
||||
|
||||
const formatDetectedRecipients = (recipients: TDetectedRecipientSchema[]) => {
|
||||
if (recipients.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const formatted = recipients
|
||||
.map((r, i) => `${i + 1}. ${r.name || '(no name)'} - ${r.email || '(no email)'} - ${r.role}`)
|
||||
.join('\n');
|
||||
|
||||
return `\n\nRecipients detected so far:\n${formatted}`;
|
||||
};
|
||||
|
||||
const isDuplicateRecipient = (
|
||||
recipient: TDetectedRecipientSchema,
|
||||
existing: TDetectedRecipientSchema,
|
||||
) => {
|
||||
if (recipient.email && existing.email) {
|
||||
return recipient.email.toLowerCase() === existing.email.toLowerCase();
|
||||
}
|
||||
|
||||
if (recipient.name && existing.name) {
|
||||
return recipient.name.toLowerCase() === existing.name.toLowerCase();
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const mergeRecipients = (
|
||||
existingRecipients: TDetectedRecipientSchema[],
|
||||
newRecipients: TDetectedRecipientSchema[],
|
||||
) => {
|
||||
const merged = [...existingRecipients];
|
||||
|
||||
for (const recipient of newRecipients) {
|
||||
const isDuplicate = merged.some((existing) => isDuplicateRecipient(recipient, existing));
|
||||
|
||||
if (!isDuplicate) {
|
||||
merged.push(recipient);
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
};
|
||||
|
||||
const buildPromptText = (options: {
|
||||
chunkIndex: number;
|
||||
totalChunks: number;
|
||||
totalPages: number;
|
||||
startPage: number;
|
||||
endPage: number;
|
||||
detectedRecipients: TDetectedRecipientSchema[];
|
||||
}) => {
|
||||
const { chunkIndex, totalChunks, totalPages, startPage, endPage, detectedRecipients } = options;
|
||||
|
||||
const isFirstChunk = chunkIndex === 0;
|
||||
const isSingleChunk = totalChunks === 1;
|
||||
const batchNumber = chunkIndex + 1;
|
||||
const previouslyFoundText = formatDetectedRecipients(detectedRecipients);
|
||||
|
||||
if (isSingleChunk) {
|
||||
return `Please analyze these ${totalPages} document page(s) and detect all recipients. Submit all detected recipients using the tool.`;
|
||||
}
|
||||
|
||||
if (isFirstChunk) {
|
||||
return `This is a ${totalPages}-page document. I'll show you the pages in batches of ${MAX_PAGES_PER_CHUNK}.
|
||||
|
||||
Here are pages ${startPage}-${endPage} (batch ${batchNumber} of ${totalChunks}).
|
||||
|
||||
Please analyze these pages and submit any recipients you find using the tool. I will show you the remaining pages after.`;
|
||||
}
|
||||
|
||||
return `Here are pages ${startPage}-${endPage} (batch ${batchNumber} of ${totalChunks}).${previouslyFoundText}
|
||||
|
||||
Please analyze these pages and submit any NEW recipients you find (not already listed above) using the tool.`;
|
||||
};
|
||||
|
||||
const detectRecipientsFromImages = async ({
|
||||
images,
|
||||
onProgress,
|
||||
}: DetectRecipientsFromImagesOptions) => {
|
||||
const imageChunks = chunk(images, MAX_PAGES_PER_CHUNK);
|
||||
|
||||
const totalChunks = imageChunks.length;
|
||||
const totalPages = images.length;
|
||||
|
||||
const messages: ModelMessage[] = [];
|
||||
let allRecipients: TDetectedRecipientSchema[] = [];
|
||||
|
||||
for (const [chunkIndex, currentChunk] of imageChunks.entries()) {
|
||||
const startPage = chunkIndex * MAX_PAGES_PER_CHUNK + 1;
|
||||
const endPage = startPage + currentChunk.length - 1;
|
||||
|
||||
const promptText = buildPromptText({
|
||||
chunkIndex,
|
||||
totalChunks,
|
||||
totalPages,
|
||||
startPage,
|
||||
endPage,
|
||||
detectedRecipients: allRecipients,
|
||||
});
|
||||
|
||||
// Add user message with images for this chunk
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: promptText,
|
||||
},
|
||||
...createImageContentParts(currentChunk),
|
||||
],
|
||||
});
|
||||
|
||||
const result = await generateObject({
|
||||
model: vertex('gemini-2.5-flash'),
|
||||
system: SYSTEM_PROMPT,
|
||||
schema: ZDetectedRecipientsSchema,
|
||||
messages,
|
||||
temperature: 0.5,
|
||||
});
|
||||
|
||||
const newRecipients = result.object?.recipients ?? [];
|
||||
|
||||
// Merge new recipients into our accumulated list (handles duplicates)
|
||||
allRecipients = mergeRecipients(allRecipients, newRecipients);
|
||||
|
||||
// Report progress (endPage represents pages processed so far)
|
||||
onProgress?.({
|
||||
pagesProcessed: endPage,
|
||||
totalPages,
|
||||
recipientsDetected: allRecipients.length,
|
||||
});
|
||||
|
||||
// Add assistant response as context for next iteration
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
content: `Detected recipients: ${JSON.stringify(allRecipients)}`,
|
||||
});
|
||||
}
|
||||
|
||||
return allRecipients;
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
export const SYSTEM_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:")
|
||||
- VIEWER: People who need to view the document (look for "Viewed by:", "View:", "Viewer:")
|
||||
- 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 the name is a placeholder name, reformat it to a more readable format (e.g. "[Insert signer A name]" -> "Signer A").
|
||||
6. If an email address is visible near a name, include it exactly in the "email" field
|
||||
7. If NO email is found, leave the email field empty.
|
||||
8. If the email is a placeholder email, leave the email field empty.
|
||||
9. 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
|
||||
- Do NOT invent recipients - only extract what's clearly present
|
||||
- If a signature line exists but no name is associated with it use an empty name and the email address (if found) of the signer.
|
||||
- Do not use placeholder names like "<UNKNOWN>", "Unknown", "Signer" unless they are explicitly mentioned in the document.
|
||||
|
||||
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" unless they are explicitly mentioned in the document.`;
|
||||
@@ -0,0 +1,24 @@
|
||||
import { RecipientRole } from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZDetectedRecipientSchema = z.object({
|
||||
name: z.string().describe('The detected recipient name, leave blank if unknown'),
|
||||
email: z.string().describe('The detected recipient email, leave blank if unknown'),
|
||||
role: z
|
||||
.nativeEnum(RecipientRole)
|
||||
.optional()
|
||||
.default(RecipientRole.SIGNER)
|
||||
.describe(
|
||||
'The detected recipient role. Use SIGNER for people who need to sign, APPROVER for approvers, CC for people who should receive a copy, VIEWER for view-only recipients',
|
||||
),
|
||||
});
|
||||
|
||||
export type TDetectedRecipientSchema = z.infer<typeof ZDetectedRecipientSchema>;
|
||||
|
||||
export const ZDetectedRecipientsSchema = z.object({
|
||||
recipients: z
|
||||
.array(ZDetectedRecipientSchema)
|
||||
.describe('The list of detected recipients from the document'),
|
||||
});
|
||||
|
||||
export type TDetectedRecipientsSchema = z.infer<typeof ZDetectedRecipientsSchema>;
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createVertex } from '@ai-sdk/google-vertex';
|
||||
|
||||
import { env } from '../../utils/env';
|
||||
|
||||
export const vertex = createVertex({
|
||||
project: env('GOOGLE_VERTEX_PROJECT_ID'),
|
||||
location: env('GOOGLE_VERTEX_LOCATION') || 'global',
|
||||
apiKey: env('GOOGLE_VERTEX_API_KEY'),
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from 'ai';
|
||||
@@ -0,0 +1,85 @@
|
||||
import pMap from 'p-map';
|
||||
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs';
|
||||
import { Canvas, Image, Path2D } from 'skia-canvas';
|
||||
|
||||
// @ts-expect-error napi-rs/canvas satisfies the requirements
|
||||
globalThis.Path2D = Path2D;
|
||||
// @ts-expect-error napi-rs/canvas satisfies the requirements
|
||||
globalThis.Image = Image;
|
||||
|
||||
class SkiaCanvasFactory {
|
||||
_createCanvas(width: number, height: number) {
|
||||
return new Canvas(width, height);
|
||||
}
|
||||
|
||||
create(width: number, height: number) {
|
||||
const canvas = this._createCanvas(width, height);
|
||||
|
||||
return {
|
||||
canvas,
|
||||
context: canvas.getContext('2d'),
|
||||
};
|
||||
}
|
||||
|
||||
reset(canvasAndContext: { canvas: Canvas }, width: number, height: number) {
|
||||
canvasAndContext.canvas.width = width;
|
||||
canvasAndContext.canvas.height = height;
|
||||
}
|
||||
|
||||
destroy(canvasAndContext: { canvas: Canvas | null; context: unknown }) {
|
||||
if (canvasAndContext.canvas) {
|
||||
canvasAndContext.canvas.width = 0;
|
||||
canvasAndContext.canvas.height = 0;
|
||||
}
|
||||
|
||||
canvasAndContext.canvas = null;
|
||||
canvasAndContext.context = null;
|
||||
}
|
||||
}
|
||||
|
||||
export type PdfToImagesOptions = {
|
||||
scale?: number;
|
||||
};
|
||||
|
||||
export const pdfToImages = async (pdfBytes: Uint8Array, options: PdfToImagesOptions = {}) => {
|
||||
const { scale = 2 } = options;
|
||||
|
||||
const pdf = await pdfjsLib.getDocument({
|
||||
data: pdfBytes,
|
||||
CanvasFactory: SkiaCanvasFactory,
|
||||
}).promise;
|
||||
|
||||
const images = await pMap(
|
||||
Array.from({ length: pdf.numPages }),
|
||||
async (_, index) => {
|
||||
const pageNumber = index + 1;
|
||||
const page = await pdf.getPage(pageNumber);
|
||||
|
||||
const viewport = page.getViewport({ scale });
|
||||
|
||||
const canvas = new Canvas(viewport.width, viewport.height);
|
||||
const canvasContext = canvas.getContext('2d');
|
||||
|
||||
await page.render({
|
||||
// @ts-expect-error napi-rs/canvas satifies the requirements
|
||||
canvas,
|
||||
// @ts-expect-error napi-rs/canvas satifies the requirements
|
||||
canvasContext,
|
||||
viewport,
|
||||
}).promise;
|
||||
|
||||
return {
|
||||
pageNumber,
|
||||
image: await canvas.toBuffer('jpeg'),
|
||||
width: Math.floor(viewport.width),
|
||||
height: Math.floor(viewport.height),
|
||||
mimeType: 'image/jpeg',
|
||||
};
|
||||
},
|
||||
{ concurrency: 10 },
|
||||
);
|
||||
|
||||
void pdf.destroy();
|
||||
|
||||
return images;
|
||||
};
|
||||
@@ -1,15 +0,0 @@
|
||||
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();
|
||||
};
|
||||
@@ -1,84 +0,0 @@
|
||||
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 results = await Promise.allSettled(
|
||||
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();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const pages = results
|
||||
.filter(
|
||||
(
|
||||
result,
|
||||
): result is PromiseFulfilledResult<{
|
||||
image: Buffer;
|
||||
pageNumber: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}> => result.status === 'fulfilled',
|
||||
)
|
||||
.map((result) => result.value);
|
||||
|
||||
if (results.some((result) => result.status === 'rejected')) {
|
||||
const failedReasons = results
|
||||
.filter((result): result is PromiseRejectedResult => result.status === 'rejected')
|
||||
.map((result) => result.reason);
|
||||
|
||||
// Note: We don't have logger available in this package
|
||||
// Errors are handled by the caller in document-analysis/index.ts
|
||||
// which will use the proper logger for reporting failures
|
||||
}
|
||||
|
||||
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, sanitizeRecipientName } from '../../utils/recipients';
|
||||
import { mapRecipientToLegacyRecipient } from '../../utils/recipients';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
|
||||
export type CreateEnvelopeRecipientsOptions = {
|
||||
export interface CreateEnvelopeRecipientsOptions {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
id: EnvelopeIdOptions;
|
||||
@@ -27,8 +27,8 @@ export type CreateEnvelopeRecipientsOptions = {
|
||||
accessAuth?: TRecipientAccessAuthTypes[];
|
||||
actionAuth?: TRecipientActionAuthTypes[];
|
||||
}[];
|
||||
requestMetadata: ApiRequestMetadata;
|
||||
};
|
||||
requestMetadata?: ApiRequestMetadata;
|
||||
}
|
||||
|
||||
export const createEnvelopeRecipients = async ({
|
||||
userId,
|
||||
@@ -85,7 +85,6 @@ 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, sanitizeRecipientName } from '../../utils/recipients';
|
||||
import { canRecipientBeModified } 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 type SetDocumentRecipientsOptions = {
|
||||
export interface SetDocumentRecipientsOptions {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
id: EnvelopeIdOptions;
|
||||
recipients: RecipientData[];
|
||||
requestMetadata: ApiRequestMetadata;
|
||||
};
|
||||
}
|
||||
|
||||
export const setDocumentRecipients = async ({
|
||||
userId,
|
||||
@@ -114,7 +114,6 @@ export const setDocumentRecipients = async ({
|
||||
|
||||
const normalizedRecipients = recipients.map((recipient) => ({
|
||||
...recipient,
|
||||
name: sanitizeRecipientName(recipient.name),
|
||||
email: recipient.email.toLowerCase(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ 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 = {
|
||||
@@ -89,7 +88,6 @@ 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, sanitizeRecipientName } from '../../utils/recipients';
|
||||
import { canRecipientBeModified } from '../../utils/recipients';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
|
||||
export type UpdateEnvelopeRecipientsOptions = {
|
||||
export interface UpdateEnvelopeRecipientsOptions {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
id: EnvelopeIdOptions;
|
||||
@@ -35,7 +35,7 @@ export type UpdateEnvelopeRecipientsOptions = {
|
||||
actionAuth?: TRecipientActionAuthTypes[];
|
||||
}[];
|
||||
requestMetadata: ApiRequestMetadata;
|
||||
};
|
||||
}
|
||||
|
||||
export const updateEnvelopeRecipients = async ({
|
||||
userId,
|
||||
@@ -108,18 +108,9 @@ export const updateEnvelopeRecipients = async ({
|
||||
});
|
||||
}
|
||||
|
||||
const sanitizedUpdateData = {
|
||||
...recipient,
|
||||
...(recipient.name !== undefined
|
||||
? {
|
||||
name: sanitizeRecipientName(recipient.name),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
return {
|
||||
originalRecipient,
|
||||
updateData: sanitizedUpdateData,
|
||||
updateData: recipient,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -2812,10 +2812,6 @@ msgstr "Created on {0}"
|
||||
msgid "CSV Structure"
|
||||
msgstr "CSV Structure"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/stats.tsx
|
||||
msgid "Cumulative MAU (signed in)"
|
||||
msgstr "Cumulative MAU (signed in)"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
|
||||
msgid "Current"
|
||||
msgstr "Current"
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: pl\n"
|
||||
"Project-Id-Version: documenso-app\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2025-11-27 05:32\n"
|
||||
"PO-Revision-Date: 2025-11-27 18:05\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Polish\n"
|
||||
"Plural-Forms: nplurals=4; plural=(n==1 ? 0 : (n%10>=2 && n%10<=4) && (n%100<12 || n%100>14) ? 1 : n!=1 && (n%10>=0 && n%10<=1) || (n%10>=5 && n%10<=9) || (n%100>=12 && n%100<=14) ? 2 : 3);\n"
|
||||
@@ -944,7 +944,7 @@ msgstr "Akcje"
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.members.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
|
||||
msgid "Active"
|
||||
msgstr "Aktywny"
|
||||
msgstr "Aktywne"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/settings+/security._index.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
|
||||
@@ -1239,7 +1239,7 @@ msgstr "Wszystkie wstawione podpisy zostaną unieważnione"
|
||||
|
||||
#: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx
|
||||
msgid "All recipients have signed. The document is being processed and you will receive an email copy shortly."
|
||||
msgstr "Wszyscy odbiorcy podpisali. Dokument jest przetwarzany i wkrótce otrzymasz jego kopię e‑mailem."
|
||||
msgstr "Wszyscy odbiorcy podpisali dokument. Dokument jest przetwarzany i wkrótce otrzymasz jego kopię."
|
||||
|
||||
#: apps/remix/app/components/dialogs/document-delete-dialog.tsx
|
||||
msgid "All recipients will be notified"
|
||||
@@ -3102,7 +3102,7 @@ msgstr "Szczegóły"
|
||||
|
||||
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
|
||||
msgid "Developer Mode"
|
||||
msgstr "Tryb deweloperski"
|
||||
msgstr "Tryb programisty"
|
||||
|
||||
#: apps/remix/app/components/tables/settings-security-activity-table.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
|
||||
@@ -3716,7 +3716,7 @@ msgstr "Przeciągnij i upuść plik PDF"
|
||||
#: packages/ui/primitives/signature-pad/signature-pad.tsx
|
||||
msgctxt "Draw signature"
|
||||
msgid "Draw"
|
||||
msgstr "Rysuj"
|
||||
msgstr "Rysowany"
|
||||
|
||||
#: apps/remix/app/components/embed/authoring/configure-document-upload.tsx
|
||||
msgid "Drop your document here"
|
||||
@@ -4256,7 +4256,7 @@ msgstr "Wszyscy podpisali"
|
||||
|
||||
#: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx
|
||||
msgid "Everyone has signed! You will receive an email copy of the signed document."
|
||||
msgstr "Wszyscy podpisali! Otrzymasz kopię podpisanego dokumentu e‑mailem."
|
||||
msgstr "Wszyscy podpisali! Otrzymasz wiadomość z podpisanym dokumentem."
|
||||
|
||||
#: apps/remix/app/components/dialogs/passkey-create-dialog.tsx
|
||||
msgid "Exceeded timeout"
|
||||
@@ -4837,7 +4837,7 @@ msgstr "Uwaga: Co to oznacza"
|
||||
|
||||
#: apps/remix/app/components/tables/user-billing-organisations-table.tsx
|
||||
msgid "Inactive"
|
||||
msgstr "Nieaktywne"
|
||||
msgstr "Nieaktywna"
|
||||
|
||||
#: apps/remix/app/components/general/app-nav-mobile.tsx
|
||||
#: apps/remix/app/components/general/app-nav-mobile.tsx
|
||||
@@ -5208,7 +5208,7 @@ msgstr "Wygenerowane linki"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
|
||||
msgid "Listening to"
|
||||
msgstr "Nasłuchuje"
|
||||
msgstr "Nasłuchiwanie"
|
||||
|
||||
#: apps/remix/app/components/general/document/document-page-view-recent-activity.tsx
|
||||
msgid "Load older activity"
|
||||
@@ -5252,7 +5252,7 @@ msgstr "Zaloguj się"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
|
||||
msgid "Logs"
|
||||
msgstr "Dzienniki"
|
||||
msgstr "Logi"
|
||||
|
||||
#: apps/remix/app/components/tables/admin-organisations-table.tsx
|
||||
#: apps/remix/app/components/tables/organisation-teams-table.tsx
|
||||
@@ -5292,7 +5292,7 @@ msgstr "Zarządzaj płatnościami"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/settings+/billing.tsx
|
||||
msgid "Manage billing and subscriptions for organisations where you have billing management permissions."
|
||||
msgstr "Zarządzaj rozliczeniami i subskrypcjami dla organizacji, w których masz uprawnienia do zarządzania rozliczeniami."
|
||||
msgstr "Zarządzaj płatnościami i subskrypcjami organizacji, w których masz uprawnienia do zarządzania płatnościami."
|
||||
|
||||
#: apps/remix/app/components/dialogs/public-profile-template-manage-dialog.tsx
|
||||
msgid "Manage details for this public template"
|
||||
@@ -6192,7 +6192,7 @@ msgstr "Hasło zostało zaktualizowane!"
|
||||
|
||||
#: apps/remix/app/components/tables/user-billing-organisations-table.tsx
|
||||
msgid "Past Due"
|
||||
msgstr "Po terminie"
|
||||
msgstr "Przeterminowana"
|
||||
|
||||
#: apps/remix/app/components/general/organisations/organisation-billing-banner.tsx
|
||||
#: apps/remix/app/components/general/organisations/organisation-billing-banner.tsx
|
||||
@@ -6412,7 +6412,7 @@ msgstr "Otwórz aplikację uwierzytelniającą i wpisz 6-cyfrowy kod dla tego do
|
||||
|
||||
#: apps/remix/app/components/general/document-signing/document-signing-reject-dialog.tsx
|
||||
msgid "Please provide a reason for rejecting this document"
|
||||
msgstr "Podaj powód odrzucenia tego dokumentu"
|
||||
msgstr "Podaj powód odrzucenia dokumentu"
|
||||
|
||||
#: apps/remix/app/components/forms/2fa/disable-authenticator-app-dialog.tsx
|
||||
msgid "Please provide a token from the authenticator, or a backup code. If you do not have a backup code available, please contact support."
|
||||
@@ -7134,7 +7134,7 @@ msgstr "Szukaj tytułu dokumentu"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks.$id._index.tsx
|
||||
msgid "Search by ID"
|
||||
msgstr "Szukaj po ID"
|
||||
msgstr "Szukaj identyfikatora"
|
||||
|
||||
#: apps/remix/app/components/tables/admin-dashboard-users-table.tsx
|
||||
msgid "Search by name or email"
|
||||
@@ -7231,7 +7231,7 @@ msgstr "Wybierz metody dostępu"
|
||||
|
||||
#: apps/remix/app/components/dialogs/webhook-test-dialog.tsx
|
||||
msgid "Select an event type"
|
||||
msgstr "Wybierz typ zdarzenia"
|
||||
msgstr "Wybierz rodzaj zdarzenia"
|
||||
|
||||
#: apps/remix/app/components/dialogs/sign-field-dropdown-dialog.tsx
|
||||
#: packages/ui/primitives/combobox.tsx
|
||||
@@ -8380,7 +8380,7 @@ msgstr "Szablony umożliwiają szybkie generowanie dokumentów z wcześniej wype
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks.$id._index.tsx
|
||||
msgid "Test"
|
||||
msgstr "Test"
|
||||
msgstr "Testuj"
|
||||
|
||||
#: apps/remix/app/components/dialogs/webhook-test-dialog.tsx
|
||||
msgid "Test Webhook"
|
||||
@@ -9257,13 +9257,13 @@ msgstr "Weryfikacja dwuetapowa"
|
||||
#: apps/remix/app/components/tables/admin-document-recipient-item-table.tsx
|
||||
#: apps/remix/app/components/tables/templates-table.tsx
|
||||
msgid "Type"
|
||||
msgstr "Typ"
|
||||
msgstr "Rodzaj"
|
||||
|
||||
#: packages/lib/constants/document.ts
|
||||
#: packages/ui/primitives/signature-pad/signature-pad.tsx
|
||||
msgctxt "Type signature"
|
||||
msgid "Type"
|
||||
msgstr "Wpisz"
|
||||
msgstr "Pisany"
|
||||
|
||||
#: apps/remix/app/components/general/app-command-menu.tsx
|
||||
msgid "Type a command or search..."
|
||||
@@ -9560,7 +9560,7 @@ msgstr "Przesłany"
|
||||
#: packages/ui/primitives/signature-pad/signature-pad.tsx
|
||||
msgctxt "Upload signature"
|
||||
msgid "Upload"
|
||||
msgstr "Prześlij"
|
||||
msgstr "Przesłany"
|
||||
|
||||
#: apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx
|
||||
msgid "Upload a CSV file to create multiple documents from this template. Each row represents one document with its recipient details."
|
||||
@@ -10350,7 +10350,7 @@ msgstr "Webhook nie został znaleziony"
|
||||
|
||||
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
|
||||
msgid "Webhook successfully sent"
|
||||
msgstr "Webhook został pomyślnie wysłany"
|
||||
msgstr "Webhook został wysłany"
|
||||
|
||||
#: apps/remix/app/components/dialogs/webhook-edit-dialog.tsx
|
||||
msgid "Webhook updated"
|
||||
@@ -10712,7 +10712,7 @@ msgstr "Nie masz uprawnień do utworzenia tokena dla tego zespołu"
|
||||
|
||||
#: apps/remix/app/components/tables/user-billing-organisations-table.tsx
|
||||
msgid "You don't manage billing for any organisations."
|
||||
msgstr "Nie zarządzasz rozliczeniami dla żadnej organizacji."
|
||||
msgstr "Nie zarządzasz płatnościami żadnej organizacji."
|
||||
|
||||
#: packages/email/template-components/template-document-cancel.tsx
|
||||
msgid "You don't need to sign it anymore."
|
||||
@@ -10931,7 +10931,7 @@ msgstr "Kod z aplikacji uwierzytelniającej będzie wymagany podczas logowania."
|
||||
|
||||
#: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx
|
||||
msgid "You will receive an email copy of the signed document once everyone has signed."
|
||||
msgstr "Otrzymasz kopię podpisanego dokumentu e‑mailem, gdy wszyscy go podpiszą."
|
||||
msgstr "Otrzymasz kopię dokumentu, gdy wszyscy go podpiszą."
|
||||
|
||||
#: apps/remix/app/routes/_unauthenticated+/organisation.sso.confirmation.$token.tsx
|
||||
msgid "Your Account"
|
||||
|
||||
@@ -2812,10 +2812,6 @@ msgstr "Criado em {0}"
|
||||
msgid "CSV Structure"
|
||||
msgstr "Estrutura do CSV"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/stats.tsx
|
||||
msgid "Cumulative MAU (signed in)"
|
||||
msgstr "MAU acumulado (logados)"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
|
||||
msgid "Current"
|
||||
msgstr "Atual"
|
||||
|
||||
@@ -2298,10 +2298,6 @@ msgstr "Krijuar më {0}"
|
||||
msgid "CSV Structure"
|
||||
msgstr "Struktura CSV"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/stats.tsx
|
||||
msgid "Cumulative MAU (signed in)"
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
|
||||
msgid "Current"
|
||||
msgstr "Aktual"
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZDetectedFormFieldSchema = z.object({
|
||||
boundingBox: z
|
||||
.object({
|
||||
ymin: z.number().min(0).max(1000),
|
||||
xmin: z.number().min(0).max(1000),
|
||||
ymax: z.number().min(0).max(1000),
|
||||
xmax: z.number().min(0).max(1000),
|
||||
})
|
||||
.refine((box) => box.xmin < box.xmax && box.ymin < box.ymax, {
|
||||
message: 'Bounding box must have min < max for both axes',
|
||||
})
|
||||
.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 type TDetectedFormField = z.infer<typeof ZDetectedFormFieldSchema>;
|
||||
@@ -63,10 +63,7 @@ export const ZEnvelopeSchema = EnvelopeSchema.pick({
|
||||
id: true,
|
||||
title: true,
|
||||
order: true,
|
||||
documentDataId: true,
|
||||
})
|
||||
.partial({ documentDataId: true })
|
||||
.array(),
|
||||
}).array(),
|
||||
directLink: TemplateDirectLinkSchema.pick({
|
||||
directTemplateRecipientId: true,
|
||||
enabled: true,
|
||||
|
||||
@@ -303,7 +303,6 @@ export const FIELD_NUMBER_META_DEFAULT_VALUES: TNumberFieldMeta = {
|
||||
textAlign: 'left',
|
||||
label: '',
|
||||
placeholder: '',
|
||||
value: '',
|
||||
required: false,
|
||||
readOnly: false,
|
||||
};
|
||||
|
||||
@@ -32,5 +32,5 @@ export const getEnvelopeItemPdfUrl = (options: EnvelopeItemPdfUrlOptions) => {
|
||||
|
||||
return token
|
||||
? `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/token/${token}/envelopeItem/${id}${presignToken ? `?presignToken=${presignToken}` : ''}`
|
||||
: `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/envelope/${envelopeId}/envelopeItem/${id}`;
|
||||
: `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/envelope/${envelopeId}/envelopeItem/${id}${presignToken ? `?token=${presignToken}` : ''}`;
|
||||
};
|
||||
|
||||
@@ -27,7 +27,7 @@ if (loggingFilePath) {
|
||||
}
|
||||
|
||||
export const logger = pino({
|
||||
level: env('LOG_LEVEL') || 'info',
|
||||
level: 'info',
|
||||
transport:
|
||||
transports.length > 0
|
||||
? {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user