mirror of
https://github.com/documenso/documenso.git
synced 2026-07-13 22:37:24 +10:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 08a9ab3aaf | |||
| e66bd422e3 | |||
| 0f5814ff89 | |||
| 1275a15571 | |||
| 22d99c7410 | |||
| 26a36487d4 | |||
| 2ee6b90c99 | |||
| f70e6ac50a | |||
| 7a94ee3b83 | |||
| e39924714a | |||
| c9604fee64 | |||
| 90f8340af4 | |||
| 28b8d2d415 | |||
| 978a2047d4 | |||
| 0dfa953f54 |
+10
-1
@@ -147,6 +147,15 @@ NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS=false
|
|||||||
# We only collect: app version, installation ID, and node ID. No personal data is collected.
|
# We only collect: app version, installation ID, and node ID. No personal data is collected.
|
||||||
DOCUMENSO_DISABLE_TELEMETRY=
|
DOCUMENSO_DISABLE_TELEMETRY=
|
||||||
|
|
||||||
|
# [[AI]]
|
||||||
|
# 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 Tests]]
|
||||||
E2E_TEST_AUTHENTICATE_USERNAME="Test User"
|
E2E_TEST_AUTHENTICATE_USERNAME="Test User"
|
||||||
E2E_TEST_AUTHENTICATE_USER_EMAIL="testuser@mail.com"
|
E2E_TEST_AUTHENTICATE_USER_EMAIL="testuser@mail.com"
|
||||||
@@ -157,4 +166,4 @@ E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_Password123"
|
|||||||
NEXT_PRIVATE_LOGGER_FILE_PATH=
|
NEXT_PRIVATE_LOGGER_FILE_PATH=
|
||||||
|
|
||||||
# [[PLAIN SUPPORT]]
|
# [[PLAIN SUPPORT]]
|
||||||
NEXT_PRIVATE_PLAIN_API_KEY=
|
NEXT_PRIVATE_PLAIN_API_KEY=
|
||||||
|
|||||||
@@ -60,3 +60,6 @@ CLAUDE.md
|
|||||||
|
|
||||||
# agents
|
# agents
|
||||||
.specs
|
.specs
|
||||||
|
|
||||||
|
# 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">
|
<img src="https://github.com/documenso/documenso/assets/13398220/a643571f-0239-46a6-a73e-6bef38d1228b" alt="Documenso Logo">
|
||||||
|
|
||||||
<p align="center" style="margin-top: 20px">
|
<p align="center" style="margin-top: 20px">
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
"@documenso/tailwind-config": "*",
|
"@documenso/tailwind-config": "*",
|
||||||
"@documenso/trpc": "*",
|
"@documenso/trpc": "*",
|
||||||
"@documenso/ui": "*",
|
"@documenso/ui": "*",
|
||||||
"next": "^15",
|
"next": "^15.5.7",
|
||||||
"next-plausible": "^3.12.5",
|
"next-plausible": "^3.12.5",
|
||||||
"nextra": "^3",
|
"nextra": "^3",
|
||||||
"nextra-theme-docs": "^3",
|
"nextra-theme-docs": "^3",
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@documenso/prisma": "*",
|
"@documenso/prisma": "*",
|
||||||
"luxon": "^3.7.2",
|
"luxon": "^3.7.2",
|
||||||
"next": "^15"
|
"next": "^15.5.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export const AdminDocumentDeleteDialog = ({ envelopeId }: AdminDocumentDeleteDia
|
|||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Document deleted`),
|
title: _(msg`Document deleted`),
|
||||||
description: 'The Document has been deleted successfully.',
|
description: _(msg`The Document has been deleted successfully.`),
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -54,8 +54,9 @@ export const AdminDocumentDeleteDialog = ({ envelopeId }: AdminDocumentDeleteDia
|
|||||||
toast({
|
toast({
|
||||||
title: _(msg`An unknown error occurred`),
|
title: _(msg`An unknown error occurred`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
description:
|
description: _(
|
||||||
'We encountered an unknown error while attempting to delete your document. Please try again later.',
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -19,7 +19,6 @@ import * as z from 'zod';
|
|||||||
|
|
||||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
|
||||||
import { trpc, trpc as trpcReact } from '@documenso/trpc/react';
|
import { trpc, trpc as trpcReact } from '@documenso/trpc/react';
|
||||||
import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper';
|
import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@@ -130,44 +129,18 @@ export const EnvelopeDistributeDialog = ({
|
|||||||
|
|
||||||
const distributionMethod = watch('meta.distributionMethod');
|
const distributionMethod = watch('meta.distributionMethod');
|
||||||
|
|
||||||
const recipientsWithIndex = useMemo(
|
|
||||||
() =>
|
|
||||||
envelope.recipients.map((recipient, index) => ({
|
|
||||||
...recipient,
|
|
||||||
index,
|
|
||||||
})),
|
|
||||||
[envelope.recipients],
|
|
||||||
);
|
|
||||||
|
|
||||||
const recipientsMissingSignatureFields = useMemo(
|
const recipientsMissingSignatureFields = useMemo(
|
||||||
() =>
|
() =>
|
||||||
recipientsWithIndex.filter(
|
envelope.recipients.filter(
|
||||||
(recipient) =>
|
(recipient) =>
|
||||||
recipient.role === RecipientRole.SIGNER &&
|
recipient.role === RecipientRole.SIGNER &&
|
||||||
!envelope.fields.some(
|
!envelope.fields.some(
|
||||||
(field) => field.type === FieldType.SIGNATURE && field.recipientId === recipient.id,
|
(field) => field.type === FieldType.SIGNATURE && field.recipientId === recipient.id,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
[recipientsWithIndex, envelope.fields],
|
[envelope.recipients, envelope.fields],
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
|
||||||
* List of recipients who must have an email due to having auth enabled.
|
|
||||||
*/
|
|
||||||
const recipientsMissingRequiredEmail = useMemo(() => {
|
|
||||||
return recipientsWithIndex.filter((recipient) => {
|
|
||||||
const auth = extractDocumentAuthMethods({
|
|
||||||
documentAuth: envelope.authOptions,
|
|
||||||
recipientAuth: recipient.authOptions,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
(auth.recipientAccessAuthRequired || auth.recipientActionAuthRequired) &&
|
|
||||||
!recipient.email
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}, [recipientsWithIndex, envelope.authOptions]);
|
|
||||||
|
|
||||||
const invalidEnvelopeCode = useMemo(() => {
|
const invalidEnvelopeCode = useMemo(() => {
|
||||||
if (recipientsMissingSignatureFields.length > 0) {
|
if (recipientsMissingSignatureFields.length > 0) {
|
||||||
return 'MISSING_SIGNATURES';
|
return 'MISSING_SIGNATURES';
|
||||||
@@ -177,12 +150,8 @@ export const EnvelopeDistributeDialog = ({
|
|||||||
return 'MISSING_RECIPIENTS';
|
return 'MISSING_RECIPIENTS';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (recipientsMissingRequiredEmail.length > 0) {
|
|
||||||
return 'MISSING_REQUIRED_EMAIL';
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}, [envelope.recipients, recipientsMissingRequiredEmail, recipientsMissingSignatureFields]);
|
}, [envelope.recipients, envelope.fields, recipientsMissingSignatureFields]);
|
||||||
|
|
||||||
const onFormSubmit = async ({ meta }: TEnvelopeDistributeFormSchema) => {
|
const onFormSubmit = async ({ meta }: TEnvelopeDistributeFormSchema) => {
|
||||||
try {
|
try {
|
||||||
@@ -475,22 +444,7 @@ export const EnvelopeDistributeDialog = ({
|
|||||||
|
|
||||||
<ul className="ml-2 mt-1 list-inside list-disc">
|
<ul className="ml-2 mt-1 list-inside list-disc">
|
||||||
{recipientsMissingSignatureFields.map((recipient) => (
|
{recipientsMissingSignatureFields.map((recipient) => (
|
||||||
<li key={recipient.id}>
|
<li key={recipient.id}>{recipient.email}</li>
|
||||||
{recipient.email || recipient.name || `Recipient ${recipient.index + 1}`}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</AlertDescription>
|
|
||||||
))
|
|
||||||
.with('MISSING_REQUIRED_EMAIL', () => (
|
|
||||||
<AlertDescription>
|
|
||||||
<Trans>The following recipients require an email address:</Trans>
|
|
||||||
|
|
||||||
<ul className="ml-2 mt-1 list-inside list-disc">
|
|
||||||
{recipientsMissingRequiredEmail.map((recipient) => (
|
|
||||||
<li key={recipient.id}>
|
|
||||||
{recipient.email || recipient.name || `Recipient ${recipient.index + 1}`}
|
|
||||||
</li>
|
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
|
|||||||
@@ -103,8 +103,8 @@ export const TemplateBulkSendDialog = ({
|
|||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Error',
|
title: _(msg`Error`),
|
||||||
description: 'Failed to upload CSV. Please check the file format and try again.',
|
description: _(msg`Failed to upload CSV. Please check the file format and try again.`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import {
|
|||||||
SKIP_QUERY_BATCH_META,
|
SKIP_QUERY_BATCH_META,
|
||||||
} from '@documenso/lib/constants/trpc';
|
} from '@documenso/lib/constants/trpc';
|
||||||
import { AppError } from '@documenso/lib/errors/app-error';
|
import { AppError } from '@documenso/lib/errors/app-error';
|
||||||
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
|
|
||||||
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@@ -66,7 +65,7 @@ const ZAddRecipientsForNewDocumentSchema = z.object({
|
|||||||
recipients: z.array(
|
recipients: z.array(
|
||||||
z.object({
|
z.object({
|
||||||
id: z.number(),
|
id: z.number(),
|
||||||
email: ZRecipientEmailSchema,
|
email: z.string().email(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
signingOrder: z.number().optional(),
|
signingOrder: z.number().optional(),
|
||||||
}),
|
}),
|
||||||
@@ -350,7 +349,7 @@ export function TemplateUseDialog({
|
|||||||
|
|
||||||
{documentDistributionMethod === DocumentDistributionMethod.EMAIL && (
|
{documentDistributionMethod === DocumentDistributionMethod.EMAIL && (
|
||||||
<label
|
<label
|
||||||
className="ml-2 flex items-center text-sm text-muted-foreground"
|
className="text-muted-foreground ml-2 flex items-center text-sm"
|
||||||
htmlFor="distributeDocument"
|
htmlFor="distributeDocument"
|
||||||
>
|
>
|
||||||
<Trans>Send document</Trans>
|
<Trans>Send document</Trans>
|
||||||
@@ -359,7 +358,7 @@ export function TemplateUseDialog({
|
|||||||
<InfoIcon className="mx-1 h-4 w-4" />
|
<InfoIcon className="mx-1 h-4 w-4" />
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
|
|
||||||
<TooltipContent className="z-[99999] max-w-md space-y-2 p-4 text-muted-foreground">
|
<TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4">
|
||||||
<p>
|
<p>
|
||||||
<Trans>
|
<Trans>
|
||||||
The document will be immediately sent to recipients if this
|
The document will be immediately sent to recipients if this
|
||||||
@@ -379,7 +378,7 @@ export function TemplateUseDialog({
|
|||||||
|
|
||||||
{documentDistributionMethod === DocumentDistributionMethod.NONE && (
|
{documentDistributionMethod === DocumentDistributionMethod.NONE && (
|
||||||
<label
|
<label
|
||||||
className="ml-2 flex items-center text-sm text-muted-foreground"
|
className="text-muted-foreground ml-2 flex items-center text-sm"
|
||||||
htmlFor="distributeDocument"
|
htmlFor="distributeDocument"
|
||||||
>
|
>
|
||||||
<Trans>Create as pending</Trans>
|
<Trans>Create as pending</Trans>
|
||||||
@@ -387,7 +386,7 @@ export function TemplateUseDialog({
|
|||||||
<TooltipTrigger type="button">
|
<TooltipTrigger type="button">
|
||||||
<InfoIcon className="mx-1 h-4 w-4" />
|
<InfoIcon className="mx-1 h-4 w-4" />
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent className="z-[99999] max-w-md space-y-2 p-4 text-muted-foreground">
|
<TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4">
|
||||||
<p>
|
<p>
|
||||||
<Trans>
|
<Trans>
|
||||||
Create the document as pending and ready to sign.
|
Create the document as pending and ready to sign.
|
||||||
@@ -433,7 +432,7 @@ export function TemplateUseDialog({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<label
|
<label
|
||||||
className="ml-2 flex items-center text-sm text-muted-foreground"
|
className="text-muted-foreground ml-2 flex items-center text-sm"
|
||||||
htmlFor="useCustomDocument"
|
htmlFor="useCustomDocument"
|
||||||
>
|
>
|
||||||
<Trans>Upload custom document</Trans>
|
<Trans>Upload custom document</Trans>
|
||||||
@@ -441,7 +440,7 @@ export function TemplateUseDialog({
|
|||||||
<TooltipTrigger type="button">
|
<TooltipTrigger type="button">
|
||||||
<InfoIcon className="mx-1 h-4 w-4" />
|
<InfoIcon className="mx-1 h-4 w-4" />
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent className="z-[99999] max-w-md space-y-2 p-4 text-muted-foreground">
|
<TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4">
|
||||||
<p>
|
<p>
|
||||||
<Trans>
|
<Trans>
|
||||||
Upload a custom document to use instead of the template's default
|
Upload a custom document to use instead of the template's default
|
||||||
@@ -471,19 +470,19 @@ export function TemplateUseDialog({
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<div
|
<div
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className="flex items-center gap-4 rounded-lg border border-border bg-card p-4 transition-colors hover:bg-accent/10"
|
className="border-border bg-card hover:bg-accent/10 flex items-center gap-4 rounded-lg border p-4 transition-colors"
|
||||||
>
|
>
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
<div className="bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg">
|
||||||
<FileTextIcon className="h-5 w-5 text-primary" />
|
<FileTextIcon className="text-primary h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<h4 className="truncate text-sm font-medium text-foreground">
|
<h4 className="text-foreground truncate text-sm font-medium">
|
||||||
{item.title}
|
{item.title}
|
||||||
</h4>
|
</h4>
|
||||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||||
{field.value ? (
|
{field.value ? (
|
||||||
<div>
|
<div>
|
||||||
<Trans>
|
<Trans>
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
ZDocumentMetaDateFormatSchema,
|
ZDocumentMetaDateFormatSchema,
|
||||||
ZDocumentMetaLanguageSchema,
|
ZDocumentMetaLanguageSchema,
|
||||||
} from '@documenso/lib/types/document-meta';
|
} from '@documenso/lib/types/document-meta';
|
||||||
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
|
|
||||||
import { DocumentDistributionMethod } from '@documenso/prisma/generated/types';
|
import { DocumentDistributionMethod } from '@documenso/prisma/generated/types';
|
||||||
|
|
||||||
// Define the schema for configuration
|
// Define the schema for configuration
|
||||||
@@ -56,7 +55,7 @@ export const ZConfigureTemplateEmbedFormSchema = ZConfigureEmbedFormSchema.exten
|
|||||||
nativeId: z.number().optional(),
|
nativeId: z.number().optional(),
|
||||||
formId: z.string(),
|
formId: z.string(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
email: ZRecipientEmailSchema,
|
email: z.union([z.string().length(0), z.string().email('Invalid email address')]),
|
||||||
role: z.enum(['SIGNER', 'CC', 'APPROVER', 'VIEWER', 'ASSISTANT']),
|
role: z.enum(['SIGNER', 'CC', 'APPROVER', 'VIEWER', 'ASSISTANT']),
|
||||||
signingOrder: z.number().optional(),
|
signingOrder: z.number().optional(),
|
||||||
disabled: z.boolean().optional(),
|
disabled: z.boolean().optional(),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { EnvelopeItem, FieldType } from '@prisma/client';
|
import type { EnvelopeItem, FieldType } from '@prisma/client';
|
||||||
@@ -229,8 +230,8 @@ export const ConfigureFieldsView = ({
|
|||||||
setFieldClipboard(lastActiveField);
|
setFieldClipboard(lastActiveField);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Copied field',
|
title: _(msg`Copied field`),
|
||||||
description: 'Copied field to clipboard',
|
description: _(msg`Copied field to clipboard`),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -150,8 +150,8 @@ export const MultiSignDocumentSigningView = ({
|
|||||||
onDocumentError?.();
|
onDocumentError?.();
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Error',
|
title: _(msg`Error`),
|
||||||
description: 'Failed to complete the document. Please try again.',
|
description: _(msg`Failed to complete the document. Please try again.`),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export type TDocumentPreferencesFormSchema = {
|
|||||||
includeSigningCertificate: boolean | null;
|
includeSigningCertificate: boolean | null;
|
||||||
includeAuditLog: boolean | null;
|
includeAuditLog: boolean | null;
|
||||||
signatureTypes: DocumentSignatureType[];
|
signatureTypes: DocumentSignatureType[];
|
||||||
|
aiFeaturesEnabled: boolean | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SettingsSubset = Pick<
|
type SettingsSubset = Pick<
|
||||||
@@ -72,11 +73,13 @@ type SettingsSubset = Pick<
|
|||||||
| 'typedSignatureEnabled'
|
| 'typedSignatureEnabled'
|
||||||
| 'uploadSignatureEnabled'
|
| 'uploadSignatureEnabled'
|
||||||
| 'drawSignatureEnabled'
|
| 'drawSignatureEnabled'
|
||||||
|
| 'aiFeaturesEnabled'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export type DocumentPreferencesFormProps = {
|
export type DocumentPreferencesFormProps = {
|
||||||
settings: SettingsSubset;
|
settings: SettingsSubset;
|
||||||
canInherit: boolean;
|
canInherit: boolean;
|
||||||
|
isAiFeaturesConfigured?: boolean;
|
||||||
onFormSubmit: (data: TDocumentPreferencesFormSchema) => Promise<void>;
|
onFormSubmit: (data: TDocumentPreferencesFormSchema) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -84,6 +87,7 @@ export const DocumentPreferencesForm = ({
|
|||||||
settings,
|
settings,
|
||||||
onFormSubmit,
|
onFormSubmit,
|
||||||
canInherit,
|
canInherit,
|
||||||
|
isAiFeaturesConfigured = false,
|
||||||
}: DocumentPreferencesFormProps) => {
|
}: DocumentPreferencesFormProps) => {
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
const { user, organisations } = useSession();
|
const { user, organisations } = useSession();
|
||||||
@@ -105,6 +109,7 @@ export const DocumentPreferencesForm = ({
|
|||||||
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(canInherit ? 0 : 1, {
|
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(canInherit ? 0 : 1, {
|
||||||
message: msg`At least one signature type must be enabled`.id,
|
message: msg`At least one signature type must be enabled`.id,
|
||||||
}),
|
}),
|
||||||
|
aiFeaturesEnabled: z.boolean().nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const form = useForm<TDocumentPreferencesFormSchema>({
|
const form = useForm<TDocumentPreferencesFormSchema>({
|
||||||
@@ -120,6 +125,7 @@ export const DocumentPreferencesForm = ({
|
|||||||
includeSigningCertificate: settings.includeSigningCertificate,
|
includeSigningCertificate: settings.includeSigningCertificate,
|
||||||
includeAuditLog: settings.includeAuditLog,
|
includeAuditLog: settings.includeAuditLog,
|
||||||
signatureTypes: extractTeamSignatureSettings({ ...settings }),
|
signatureTypes: extractTeamSignatureSettings({ ...settings }),
|
||||||
|
aiFeaturesEnabled: settings.aiFeaturesEnabled,
|
||||||
},
|
},
|
||||||
resolver: zodResolver(ZDocumentPreferencesFormSchema),
|
resolver: zodResolver(ZDocumentPreferencesFormSchema),
|
||||||
});
|
});
|
||||||
@@ -312,7 +318,7 @@ export const DocumentPreferencesForm = ({
|
|||||||
}))}
|
}))}
|
||||||
selectedValues={field.value}
|
selectedValues={field.value}
|
||||||
onChange={field.onChange}
|
onChange={field.onChange}
|
||||||
className="bg-background w-full"
|
className="w-full bg-background"
|
||||||
enableSearch={false}
|
enableSearch={false}
|
||||||
emptySelectionPlaceholder={
|
emptySelectionPlaceholder={
|
||||||
canInherit ? t`Inherit from organisation` : t`Select signature types`
|
canInherit ? t`Inherit from organisation` : t`Select signature types`
|
||||||
@@ -378,7 +384,7 @@ export const DocumentPreferencesForm = ({
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<div className="pt-2">
|
<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>
|
<Trans>Preview</Trans>
|
||||||
</div>
|
</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">
|
<div className="flex flex-row justify-end space-x-4">
|
||||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||||
<Trans>Update</Trans>
|
<Trans>Update</Trans>
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ export const SignInForm = ({
|
|||||||
.otherwise(() => handleFallbackErrorMessages(error.code));
|
.otherwise(() => handleFallbackErrorMessages(error.code));
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Something went wrong',
|
title: _(msg`Something went wrong`),
|
||||||
description: _(errorMessage),
|
description: _(errorMessage),
|
||||||
duration: 10000,
|
duration: 10000,
|
||||||
variant: 'destructive',
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+17
-17
@@ -57,7 +57,7 @@ export type DocumentSigningCompleteDialogProps = {
|
|||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
};
|
};
|
||||||
recipientPayload?: {
|
directTemplatePayload?: {
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
};
|
};
|
||||||
@@ -89,7 +89,7 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
recipient,
|
recipient,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
allowDictateNextSigner = false,
|
allowDictateNextSigner = false,
|
||||||
recipientPayload,
|
directTemplatePayload,
|
||||||
defaultNextSigner,
|
defaultNextSigner,
|
||||||
buttonSize = 'lg',
|
buttonSize = 'lg',
|
||||||
position,
|
position,
|
||||||
@@ -113,11 +113,11 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const recipientForm = useForm<TDirectRecipientFormSchema>({
|
const directRecipientForm = useForm<TDirectRecipientFormSchema>({
|
||||||
resolver: zodResolver(ZDirectRecipientFormSchema),
|
resolver: zodResolver(ZDirectRecipientFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: recipientPayload?.name ?? '',
|
name: directTemplatePayload?.name ?? '',
|
||||||
email: recipientPayload?.email ?? '',
|
email: directTemplatePayload?.email ?? '',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -145,16 +145,16 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
|
|
||||||
const onFormSubmit = async (data: TNextSignerFormSchema) => {
|
const onFormSubmit = async (data: TNextSignerFormSchema) => {
|
||||||
try {
|
try {
|
||||||
let recipientOverridePayload: { name: string; email: string } | undefined;
|
let directRecipient: { name: string; email: string } | undefined;
|
||||||
|
|
||||||
if (recipientPayload && !recipientPayload.email) {
|
if (directTemplatePayload && !directTemplatePayload.email) {
|
||||||
const isFormValid = await recipientForm.trigger();
|
const isFormValid = await directRecipientForm.trigger();
|
||||||
|
|
||||||
if (!isFormValid) {
|
if (!isFormValid) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
recipientOverridePayload = recipientForm.getValues();
|
directRecipient = directRecipientForm.getValues();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if 2FA is required
|
// Check if 2FA is required
|
||||||
@@ -168,7 +168,7 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
? { name: data.name, email: data.email }
|
? { name: data.name, email: data.email }
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
await onSignatureComplete(nextSigner, data.accessAuthOptions, recipientOverridePayload);
|
await onSignatureComplete(nextSigner, data.accessAuthOptions, directRecipient);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = AppError.parseError(error);
|
const err = AppError.parseError(error);
|
||||||
|
|
||||||
@@ -222,7 +222,7 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
<Trans>Are you sure?</Trans>
|
<Trans>Are you sure?</Trans>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
<div className="max-w-[50ch] text-muted-foreground">
|
<div className="text-muted-foreground max-w-[50ch]">
|
||||||
{match(recipient.role)
|
{match(recipient.role)
|
||||||
.with(RecipientRole.VIEWER, () => (
|
.with(RecipientRole.VIEWER, () => (
|
||||||
<span className="inline-flex flex-wrap">
|
<span className="inline-flex flex-wrap">
|
||||||
@@ -250,19 +250,19 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="rounded-lg border border-border bg-muted/50 p-4 text-center">
|
<div className="border-border bg-muted/50 rounded-lg border p-4 text-center">
|
||||||
<p className="text-sm font-medium text-muted-foreground">{documentTitle}</p>
|
<p className="text-muted-foreground text-sm font-medium">{documentTitle}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!showTwoFactorForm && (
|
{!showTwoFactorForm && (
|
||||||
<>
|
<>
|
||||||
<fieldset disabled={form.formState.isSubmitting} className="border-none p-0">
|
<fieldset disabled={form.formState.isSubmitting} className="border-none p-0">
|
||||||
{recipientPayload && !recipientPayload.email && (
|
{directTemplatePayload && !directTemplatePayload.email && (
|
||||||
<Form {...recipientForm}>
|
<Form {...directRecipientForm}>
|
||||||
<div className="mb-4 flex flex-col gap-4">
|
<div className="mb-4 flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-4 md:flex-row">
|
<div className="flex flex-col gap-4 md:flex-row">
|
||||||
<FormField
|
<FormField
|
||||||
control={recipientForm.control}
|
control={directRecipientForm.control}
|
||||||
name="name"
|
name="name"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex-1">
|
<FormItem className="flex-1">
|
||||||
@@ -284,7 +284,7 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={recipientForm.control}
|
control={directRecipientForm.control}
|
||||||
name="email"
|
name="email"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex-1">
|
<FormItem className="flex-1">
|
||||||
|
|||||||
@@ -108,8 +108,8 @@ export const DocumentSigningForm = ({
|
|||||||
await completeDocument({ nextSigner });
|
await completeDocument({ nextSigner });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Error',
|
title: _(msg`Error`),
|
||||||
description: 'An error occurred while completing the document. Please try again.',
|
description: _(msg`An error occurred while completing the document. Please try again.`),
|
||||||
variant: 'destructive',
|
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="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]">
|
<div className="max-w-[50ch]">
|
||||||
<span className="truncate text-muted-foreground" title={senderName}>
|
|
||||||
{senderName} {senderEmail}
|
|
||||||
</span>{' '}
|
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{match(recipient.role)
|
{match(recipient.role)
|
||||||
.with(RecipientRole.VIEWER, () =>
|
.with(RecipientRole.VIEWER, () =>
|
||||||
includeSenderDetails ? (
|
includeSenderDetails ? (
|
||||||
<Trans>
|
<Trans>
|
||||||
|
<span className="truncate" title={senderName}>
|
||||||
|
{senderName} {senderEmail}
|
||||||
|
</span>{' '}
|
||||||
on behalf of "{document.team?.name}" has invited you to view this document
|
on behalf of "{document.team?.name}" has invited you to view this document
|
||||||
</Trans>
|
</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, () =>
|
.with(RecipientRole.SIGNER, () =>
|
||||||
includeSenderDetails ? (
|
includeSenderDetails ? (
|
||||||
<Trans>
|
<Trans>
|
||||||
|
<span className="truncate" title={senderName}>
|
||||||
|
{senderName} {senderEmail}
|
||||||
|
</span>{' '}
|
||||||
on behalf of "{document.team?.name}" has invited you to sign this document
|
on behalf of "{document.team?.name}" has invited you to sign this document
|
||||||
</Trans>
|
</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, () =>
|
.with(RecipientRole.APPROVER, () =>
|
||||||
includeSenderDetails ? (
|
includeSenderDetails ? (
|
||||||
<Trans>
|
<Trans>
|
||||||
|
<span className="truncate" title={senderName}>
|
||||||
|
{senderName} {senderEmail}
|
||||||
|
</span>{' '}
|
||||||
on behalf of "{document.team?.name}" has invited you to approve this document
|
on behalf of "{document.team?.name}" has invited you to approve this document
|
||||||
</Trans>
|
</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, () =>
|
.with(RecipientRole.ASSISTANT, () =>
|
||||||
includeSenderDetails ? (
|
includeSenderDetails ? (
|
||||||
<Trans>
|
<Trans>
|
||||||
|
<span className="truncate" title={senderName}>
|
||||||
|
{senderName} {senderEmail}
|
||||||
|
</span>{' '}
|
||||||
on behalf of "{document.team?.name}" has invited you to assist this document
|
on behalf of "{document.team?.name}" has invited you to assist this document
|
||||||
</Trans>
|
</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)}
|
.otherwise(() => null)}
|
||||||
|
|||||||
+4
-4
@@ -74,8 +74,8 @@ export function DocumentSigningRejectDialog({
|
|||||||
});
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Document rejected',
|
title: t`Document rejected`,
|
||||||
description: 'The document has been successfully rejected.',
|
description: t`The document has been successfully rejected.`,
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -88,8 +88,8 @@ export function DocumentSigningRejectDialog({
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Error',
|
title: t`Error`,
|
||||||
description: 'An error occurred while rejecting the document. Please try again.',
|
description: t`An error occurred while rejecting the document. Please try again.`,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,28 +1,31 @@
|
|||||||
import { lazy, useEffect, useMemo } from 'react';
|
import { lazy, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import type { MessageDescriptor } from '@lingui/core';
|
import type { MessageDescriptor } from '@lingui/core';
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
import { useLingui } from '@lingui/react';
|
||||||
import { FieldType, RecipientRole } from '@prisma/client';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { FileTextIcon } from 'lucide-react';
|
import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
|
||||||
|
import { FileTextIcon, SparklesIcon } from 'lucide-react';
|
||||||
import { Link, useSearchParams } from 'react-router';
|
import { Link, useSearchParams } from 'react-router';
|
||||||
import { isDeepEqual } from 'remeda';
|
import { isDeepEqual } from 'remeda';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||||
import type {
|
import type { NormalizedFieldWithContext } from '@documenso/lib/server-only/ai/envelope/detect-fields/types';
|
||||||
TCheckboxFieldMeta,
|
import {
|
||||||
TDateFieldMeta,
|
FIELD_META_DEFAULT_VALUES,
|
||||||
TDropdownFieldMeta,
|
type TCheckboxFieldMeta,
|
||||||
TEmailFieldMeta,
|
type TDateFieldMeta,
|
||||||
TFieldMetaSchema,
|
type TDropdownFieldMeta,
|
||||||
TInitialsFieldMeta,
|
type TEmailFieldMeta,
|
||||||
TNameFieldMeta,
|
type TFieldMetaSchema,
|
||||||
TNumberFieldMeta,
|
type TInitialsFieldMeta,
|
||||||
TRadioFieldMeta,
|
type TNameFieldMeta,
|
||||||
TSignatureFieldMeta,
|
type TNumberFieldMeta,
|
||||||
TTextFieldMeta,
|
type TRadioFieldMeta,
|
||||||
|
type TSignatureFieldMeta,
|
||||||
|
type TTextFieldMeta,
|
||||||
} from '@documenso/lib/types/field-meta';
|
} from '@documenso/lib/types/field-meta';
|
||||||
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
|
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
|
||||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||||
@@ -31,6 +34,7 @@ import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/al
|
|||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Separator } from '@documenso/ui/primitives/separator';
|
import { Separator } from '@documenso/ui/primitives/separator';
|
||||||
|
|
||||||
|
import { AiFieldDetectionDialog } from '~/components/dialogs/ai-field-detection-dialog';
|
||||||
import { EditorFieldCheckboxForm } from '~/components/forms/editor/editor-field-checkbox-form';
|
import { EditorFieldCheckboxForm } from '~/components/forms/editor/editor-field-checkbox-form';
|
||||||
import { EditorFieldDateForm } from '~/components/forms/editor/editor-field-date-form';
|
import { EditorFieldDateForm } from '~/components/forms/editor/editor-field-date-form';
|
||||||
import { EditorFieldDropdownForm } from '~/components/forms/editor/editor-field-dropdown-form';
|
import { EditorFieldDropdownForm } from '~/components/forms/editor/editor-field-dropdown-form';
|
||||||
@@ -41,6 +45,7 @@ import { EditorFieldNumberForm } from '~/components/forms/editor/editor-field-nu
|
|||||||
import { EditorFieldRadioForm } from '~/components/forms/editor/editor-field-radio-form';
|
import { EditorFieldRadioForm } from '~/components/forms/editor/editor-field-radio-form';
|
||||||
import { EditorFieldSignatureForm } from '~/components/forms/editor/editor-field-signature-form';
|
import { EditorFieldSignatureForm } from '~/components/forms/editor/editor-field-signature-form';
|
||||||
import { EditorFieldTextForm } from '~/components/forms/editor/editor-field-text-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 { EnvelopeEditorFieldDragDrop } from './envelope-editor-fields-drag-drop';
|
||||||
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
|
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
|
||||||
@@ -67,11 +72,15 @@ const FieldSettingsTypeTranslations: Record<FieldType, MessageDescriptor> = {
|
|||||||
export const EnvelopeEditorFieldsPage = () => {
|
export const EnvelopeEditorFieldsPage = () => {
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
const { envelope, editorFields, relativePath } = useCurrentEnvelopeEditor();
|
const { envelope, editorFields, relativePath } = useCurrentEnvelopeEditor();
|
||||||
|
|
||||||
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||||
|
|
||||||
const { t } = useLingui();
|
const { _ } = useLingui();
|
||||||
|
|
||||||
|
const [isAiFieldDialogOpen, setIsAiFieldDialogOpen] = useState(false);
|
||||||
|
|
||||||
const selectedField = useMemo(
|
const selectedField = useMemo(
|
||||||
() => structuredClone(editorFields.selectedField),
|
() => structuredClone(editorFields.selectedField),
|
||||||
@@ -96,6 +105,24 @@ export const EnvelopeEditorFieldsPage = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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.
|
* Set the selected recipient to the first recipient in the envelope.
|
||||||
*/
|
*/
|
||||||
@@ -202,6 +229,35 @@ export const EnvelopeEditorFieldsPage = () => {
|
|||||||
selectedRecipientId={editorFields.selectedRecipient?.id ?? null}
|
selectedRecipientId={editorFields.selectedRecipient?.id ?? null}
|
||||||
selectedEnvelopeItemId={currentEnvelopeItem?.id ?? null}
|
selectedEnvelopeItemId={currentEnvelopeItem?.id ?? null}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{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>
|
||||||
|
|
||||||
|
<AiFieldDetectionDialog
|
||||||
|
open={isAiFieldDialogOpen}
|
||||||
|
onOpenChange={setIsAiFieldDialogOpen}
|
||||||
|
onComplete={onFieldDetectionComplete}
|
||||||
|
envelopeId={envelope.id}
|
||||||
|
teamId={envelope.teamId}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Field details section. */}
|
{/* Field details section. */}
|
||||||
@@ -243,7 +299,7 @@ export const EnvelopeEditorFieldsPage = () => {
|
|||||||
|
|
||||||
<div className="px-4 [&_label]:text-xs [&_label]:text-foreground/70">
|
<div className="px-4 [&_label]:text-xs [&_label]:text-foreground/70">
|
||||||
<h3 className="text-sm font-semibold">
|
<h3 className="text-sm font-semibold">
|
||||||
{t(FieldSettingsTypeTranslations[selectedField.type])}
|
{_(FieldSettingsTypeTranslations[selectedField.type])}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{match(selectedField.type)
|
{match(selectedField.type)
|
||||||
|
|||||||
+158
-14
@@ -8,11 +8,13 @@ import {
|
|||||||
type SensorAPI,
|
type SensorAPI,
|
||||||
} from '@hello-pangea/dnd';
|
} from '@hello-pangea/dnd';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
import { DocumentSigningOrder, EnvelopeType, RecipientRole, SendStatus } from '@prisma/client';
|
import { DocumentSigningOrder, EnvelopeType, RecipientRole, SendStatus } from '@prisma/client';
|
||||||
import { motion } from 'framer-motion';
|
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 { useFieldArray, useForm, useWatch } from 'react-hook-form';
|
||||||
|
import { useSearchParams } from 'react-router';
|
||||||
import { isDeepEqual, prop, sortBy } from 'remeda';
|
import { isDeepEqual, prop, sortBy } from 'remeda';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
@@ -21,11 +23,11 @@ import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounce
|
|||||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
|
import type { TDetectedRecipientSchema } from '@documenso/lib/server-only/ai/envelope/detect-recipients/schema';
|
||||||
import {
|
import {
|
||||||
ZRecipientActionAuthTypesSchema,
|
ZRecipientActionAuthTypesSchema,
|
||||||
ZRecipientAuthOptionsSchema,
|
ZRecipientAuthOptionsSchema,
|
||||||
} from '@documenso/lib/types/document-auth';
|
} from '@documenso/lib/types/document-auth';
|
||||||
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
|
|
||||||
import { nanoid } from '@documenso/lib/universal/id';
|
import { nanoid } from '@documenso/lib/universal/id';
|
||||||
import { canRecipientBeModified as utilCanRecipientBeModified } from '@documenso/lib/utils/recipients';
|
import { canRecipientBeModified as utilCanRecipientBeModified } from '@documenso/lib/utils/recipients';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
@@ -60,12 +62,18 @@ import { Input } from '@documenso/ui/primitives/input';
|
|||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
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({
|
const ZEnvelopeRecipientsForm = z.object({
|
||||||
signers: z.array(
|
signers: z.array(
|
||||||
z.object({
|
z.object({
|
||||||
formId: z.string().min(1),
|
formId: z.string().min(1),
|
||||||
id: z.number().optional(),
|
id: z.number().optional(),
|
||||||
email: ZRecipientEmailSchema,
|
email: z
|
||||||
|
.string()
|
||||||
|
.email({ message: msg`Invalid email`.id })
|
||||||
|
.min(1),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
role: z.nativeEnum(RecipientRole),
|
role: z.nativeEnum(RecipientRole),
|
||||||
signingOrder: z.number().optional(),
|
signingOrder: z.number().optional(),
|
||||||
@@ -82,14 +90,36 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
const { envelope, setRecipientsDebounced, updateEnvelope } = useCurrentEnvelopeEditor();
|
const { envelope, setRecipientsDebounced, updateEnvelope } = useCurrentEnvelopeEditor();
|
||||||
|
|
||||||
const organisation = useCurrentOrganisation();
|
const organisation = useCurrentOrganisation();
|
||||||
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { remaining } = useLimits();
|
const { remaining } = useLimits();
|
||||||
const { user } = useSession();
|
const { user } = useSession();
|
||||||
|
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [recipientSearchQuery, setRecipientSearchQuery] = useState('');
|
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 debouncedRecipientSearchQuery = useDebouncedValue(recipientSearchQuery, 500);
|
||||||
|
|
||||||
const initialId = useId();
|
const initialId = useId();
|
||||||
@@ -198,13 +228,12 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
keyName: 'nativeId',
|
keyName: 'nativeId',
|
||||||
});
|
});
|
||||||
|
|
||||||
const emptySignerIndex = watchedSigners.findIndex(
|
const emptySigners = useCallback(
|
||||||
(signer) =>
|
() => form.getValues('signers').filter((signer) => signer.email === ''),
|
||||||
!signer.name &&
|
[form],
|
||||||
!signer.email &&
|
|
||||||
envelope.fields.filter((field) => field.recipientId === signer.id).length === 0,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const emptySignerIndex = watchedSigners.findIndex((signer) => !signer.name && !signer.email);
|
||||||
const isUserAlreadyARecipient = watchedSigners.some(
|
const isUserAlreadyARecipient = watchedSigners.some(
|
||||||
(signer) => signer.email.toLowerCase() === user?.email?.toLowerCase(),
|
(signer) => signer.email.toLowerCase() === user?.email?.toLowerCase(),
|
||||||
);
|
);
|
||||||
@@ -242,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 onRemoveSigner = (index: number) => {
|
||||||
const signer = signers[index];
|
const signer = signers[index];
|
||||||
|
|
||||||
@@ -304,8 +398,14 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
index: number,
|
index: number,
|
||||||
suggestion: RecipientAutoCompleteOption,
|
suggestion: RecipientAutoCompleteOption,
|
||||||
) => {
|
) => {
|
||||||
setValue(`signers.${index}.email`, suggestion.email);
|
setValue(`signers.${index}.email`, suggestion.email, {
|
||||||
setValue(`signers.${index}.name`, suggestion.name || '');
|
shouldValidate: true,
|
||||||
|
shouldDirty: true,
|
||||||
|
});
|
||||||
|
setValue(`signers.${index}.name`, suggestion.name || '', {
|
||||||
|
shouldValidate: true,
|
||||||
|
shouldDirty: true,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const onDragEnd = useCallback(
|
const onDragEnd = useCallback(
|
||||||
@@ -458,7 +558,21 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const validatedFormValues = ZEnvelopeRecipientsForm.safeParse(formValues);
|
const formValueSigners = formValues.signers || [];
|
||||||
|
|
||||||
|
// Remove the last signer if it's empty.
|
||||||
|
const nonEmptyRecipients = formValueSigners.filter((signer, i) => {
|
||||||
|
if (i === formValueSigners.length - 1 && signer.email === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const validatedFormValues = ZEnvelopeRecipientsForm.safeParse({
|
||||||
|
...formValues,
|
||||||
|
signers: nonEmptyRecipients,
|
||||||
|
});
|
||||||
|
|
||||||
if (!validatedFormValues.success) {
|
if (!validatedFormValues.success) {
|
||||||
return;
|
return;
|
||||||
@@ -527,6 +641,26 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-row items-center space-x-2">
|
<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
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="flex flex-row items-center"
|
className="flex flex-row items-center"
|
||||||
@@ -602,7 +736,9 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={isSubmitting || hasDocumentBeenSent}
|
disabled={
|
||||||
|
isSubmitting || hasDocumentBeenSent || emptySigners().length !== 0
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
@@ -698,7 +834,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
>
|
>
|
||||||
{signers.map((signer, index) => (
|
{signers.map((signer, index) => (
|
||||||
<Draggable
|
<Draggable
|
||||||
key={`${signer.id}-${signer.signingOrder}`}
|
key={`${signer.nativeId}-${signer.signingOrder}`}
|
||||||
draggableId={signer['nativeId']}
|
draggableId={signer['nativeId']}
|
||||||
index={index}
|
index={index}
|
||||||
isDragDisabled={
|
isDragDisabled={
|
||||||
@@ -788,7 +924,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{!showAdvancedSettings && index === 0 && (
|
{!showAdvancedSettings && index === 0 && (
|
||||||
<FormLabel>
|
<FormLabel required>
|
||||||
<Trans>Email</Trans>
|
<Trans>Email</Trans>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
)}
|
)}
|
||||||
@@ -974,6 +1110,14 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
onOpenChange={setShowSigningOrderConfirmation}
|
onOpenChange={setShowSigningOrderConfirmation}
|
||||||
onConfirm={handleSigningOrderDisable}
|
onConfirm={handleSigningOrderDisable}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<AiRecipientDetectionDialog
|
||||||
|
open={isAiDialogOpen}
|
||||||
|
onOpenChange={onAiDialogOpenChange}
|
||||||
|
onComplete={onAiDetectionComplete}
|
||||||
|
envelopeId={envelope.id}
|
||||||
|
teamId={envelope.teamId}
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useMemo, useState } from 'react';
|
|||||||
|
|
||||||
import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd';
|
import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd';
|
||||||
import type { DropResult } from '@hello-pangea/dnd';
|
import type { DropResult } from '@hello-pangea/dnd';
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg, plural } from '@lingui/core/macro';
|
||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
import { DocumentStatus } from '@prisma/client';
|
import { DocumentStatus } from '@prisma/client';
|
||||||
import { FileWarningIcon, GripVerticalIcon, Loader2 } from 'lucide-react';
|
import { FileWarningIcon, GripVerticalIcon, Loader2 } from 'lucide-react';
|
||||||
@@ -226,7 +226,12 @@ export const EnvelopeEditorUploadPage = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (maximumEnvelopeItemCount <= localFiles.length) {
|
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;
|
return null;
|
||||||
@@ -240,7 +245,10 @@ export const EnvelopeEditorUploadPage = () => {
|
|||||||
|
|
||||||
if (maxItemsReached) {
|
if (maxItemsReached) {
|
||||||
toast({
|
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,
|
duration: 5000,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -41,11 +41,6 @@ export const EnvelopeRecipientSelector = ({
|
|||||||
}: EnvelopeRecipientSelectorProps) => {
|
}: EnvelopeRecipientSelectorProps) => {
|
||||||
const [showRecipientsSelector, setShowRecipientsSelector] = useState(false);
|
const [showRecipientsSelector, setShowRecipientsSelector] = useState(false);
|
||||||
|
|
||||||
const getRecipientLabel = useCallback(
|
|
||||||
(recipient: Recipient) => extractRecipientLabel(recipient, recipients),
|
|
||||||
[recipients],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover open={showRecipientsSelector} onOpenChange={setShowRecipientsSelector}>
|
<Popover open={showRecipientsSelector} onOpenChange={setShowRecipientsSelector}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
@@ -54,7 +49,7 @@ export const EnvelopeRecipientSelector = ({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
role="combobox"
|
role="combobox"
|
||||||
className={cn(
|
className={cn(
|
||||||
'justify-between bg-background font-normal text-muted-foreground hover:text-foreground',
|
'bg-background text-muted-foreground hover:text-foreground justify-between font-normal',
|
||||||
getRecipientColorStyles(
|
getRecipientColorStyles(
|
||||||
Math.max(
|
Math.max(
|
||||||
recipients.findIndex((r) => r.id === selectedRecipient?.id),
|
recipients.findIndex((r) => r.id === selectedRecipient?.id),
|
||||||
@@ -64,12 +59,16 @@ export const EnvelopeRecipientSelector = ({
|
|||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{selectedRecipient && (
|
{selectedRecipient?.email && (
|
||||||
<span className="flex-1 truncate text-left">
|
<span className="flex-1 truncate text-left">
|
||||||
{getRecipientLabel(selectedRecipient)}
|
{selectedRecipient?.name} ({selectedRecipient?.email})
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!selectedRecipient?.email && (
|
||||||
|
<span className="flex-1 truncate text-left">{selectedRecipient?.email}</span>
|
||||||
|
)}
|
||||||
|
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4" />
|
<ChevronsUpDown className="ml-2 h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
@@ -155,11 +154,6 @@ export const EnvelopeRecipientSelectorCommand = ({
|
|||||||
[fields, recipients],
|
[fields, recipients],
|
||||||
);
|
);
|
||||||
|
|
||||||
const getRecipientLabel = useCallback(
|
|
||||||
(recipient: Recipient) => extractRecipientLabel(recipient, recipients),
|
|
||||||
[recipients],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Command
|
<Command
|
||||||
value={selectedRecipient ? selectedRecipient.id.toString() : undefined}
|
value={selectedRecipient ? selectedRecipient.id.toString() : undefined}
|
||||||
@@ -168,21 +162,21 @@ export const EnvelopeRecipientSelectorCommand = ({
|
|||||||
<CommandInput placeholder={placeholder} />
|
<CommandInput placeholder={placeholder} />
|
||||||
|
|
||||||
<CommandEmpty>
|
<CommandEmpty>
|
||||||
<span className="inline-block px-4 text-muted-foreground">
|
<span className="text-muted-foreground inline-block px-4">
|
||||||
<Trans>No recipient matching this description was found.</Trans>
|
<Trans>No recipient matching this description was found.</Trans>
|
||||||
</span>
|
</span>
|
||||||
</CommandEmpty>
|
</CommandEmpty>
|
||||||
|
|
||||||
{recipientsByRoleToDisplay().map(([role, roleRecipients], roleIndex) => (
|
{recipientsByRoleToDisplay().map(([role, roleRecipients], roleIndex) => (
|
||||||
<CommandGroup key={roleIndex}>
|
<CommandGroup key={roleIndex}>
|
||||||
<div className="mb-1 ml-2 mt-2 text-xs font-medium text-muted-foreground">
|
<div className="text-muted-foreground mb-1 ml-2 mt-2 text-xs font-medium">
|
||||||
{t(RECIPIENT_ROLES_DESCRIPTION[role].roleNamePlural)}
|
{t(RECIPIENT_ROLES_DESCRIPTION[role].roleNamePlural)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{roleRecipients.length === 0 && (
|
{roleRecipients.length === 0 && (
|
||||||
<div
|
<div
|
||||||
key={`${role}-empty`}
|
key={`${role}-empty`}
|
||||||
className="px-4 pb-4 pt-2.5 text-center text-xs text-muted-foreground/80"
|
className="text-muted-foreground/80 px-4 pb-4 pt-2.5 text-center text-xs"
|
||||||
>
|
>
|
||||||
<Trans>No recipients with this role</Trans>
|
<Trans>No recipients with this role</Trans>
|
||||||
</div>
|
</div>
|
||||||
@@ -211,12 +205,18 @@ export const EnvelopeRecipientSelectorCommand = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={cn('truncate text-foreground/70', {
|
className={cn('text-foreground/70 truncate', {
|
||||||
'text-foreground/80': recipient.id === selectedRecipient?.id,
|
'text-foreground/80': recipient.id === selectedRecipient?.id,
|
||||||
'opacity-50': isRecipientDisabled(recipient.id),
|
'opacity-50': isRecipientDisabled(recipient.id),
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{getRecipientLabel(recipient)}
|
{recipient.name && (
|
||||||
|
<span title={`${recipient.name} (${recipient.email})`}>
|
||||||
|
{recipient.name} ({recipient.email})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!recipient.name && <span title={recipient.email}>{recipient.email}</span>}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div className="ml-auto flex items-center justify-center">
|
<div className="ml-auto flex items-center justify-center">
|
||||||
@@ -234,7 +234,7 @@ export const EnvelopeRecipientSelectorCommand = ({
|
|||||||
<Info className="z-50 ml-2 h-4 w-4" />
|
<Info className="z-50 ml-2 h-4 w-4" />
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
|
|
||||||
<TooltipContent className="max-w-xs text-muted-foreground">
|
<TooltipContent className="text-muted-foreground max-w-xs">
|
||||||
<Trans>
|
<Trans>
|
||||||
This document has already been sent to this recipient. You can no longer
|
This document has already been sent to this recipient. You can no longer
|
||||||
edit this recipient.
|
edit this recipient.
|
||||||
@@ -250,22 +250,3 @@ export const EnvelopeRecipientSelectorCommand = ({
|
|||||||
</Command>
|
</Command>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const extractRecipientLabel = (recipient: Recipient, recipients: Recipient[]) => {
|
|
||||||
if (recipient.name && recipient.email) {
|
|
||||||
return `${recipient.name} (${recipient.email})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (recipient.name) {
|
|
||||||
return recipient.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (recipient.email) {
|
|
||||||
return recipient.email;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Since objects are basically pointers we can use `indexOf` rather than `findIndex`
|
|
||||||
const index = recipients.indexOf(recipient);
|
|
||||||
|
|
||||||
return `Recipient ${index + 1}`;
|
|
||||||
};
|
|
||||||
|
|||||||
+3
-14
@@ -80,14 +80,12 @@ export const EnvelopeSignerCompleteDialog = () => {
|
|||||||
const handleOnCompleteClick = async (
|
const handleOnCompleteClick = async (
|
||||||
nextSigner?: { name: string; email: string },
|
nextSigner?: { name: string; email: string },
|
||||||
accessAuthOptions?: TRecipientAccessAuth,
|
accessAuthOptions?: TRecipientAccessAuth,
|
||||||
recipientDetails?: { name: string; email: string },
|
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
await completeDocument({
|
await completeDocument({
|
||||||
token: recipient.token,
|
token: recipient.token,
|
||||||
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
|
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
|
||||||
accessAuthOptions,
|
accessAuthOptions,
|
||||||
recipientOverride: recipientDetails,
|
|
||||||
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
|
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -207,18 +205,9 @@ export const EnvelopeSignerCompleteDialog = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const recipientPayload = useMemo(() => {
|
const directTemplatePayload = useMemo(() => {
|
||||||
if (!isDirectTemplate) {
|
if (!isDirectTemplate) {
|
||||||
return {
|
return;
|
||||||
name:
|
|
||||||
recipient.name ||
|
|
||||||
recipient.fields.find((field) => field.type === FieldType.NAME)?.customText ||
|
|
||||||
'',
|
|
||||||
email:
|
|
||||||
recipient.email ||
|
|
||||||
recipient.fields.find((field) => field.type === FieldType.EMAIL)?.customText ||
|
|
||||||
'',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -230,7 +219,7 @@ export const EnvelopeSignerCompleteDialog = () => {
|
|||||||
return (
|
return (
|
||||||
<DocumentSigningCompleteDialog
|
<DocumentSigningCompleteDialog
|
||||||
isSubmitting={isPending}
|
isSubmitting={isPending}
|
||||||
recipientPayload={recipientPayload}
|
directTemplatePayload={directTemplatePayload}
|
||||||
onSignatureComplete={
|
onSignatureComplete={
|
||||||
isDirectTemplate ? handleDirectTemplateCompleteClick : handleOnCompleteClick
|
isDirectTemplate ? handleDirectTemplateCompleteClick : handleOnCompleteClick
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { type ReactNode, useState } from 'react';
|
import { type ReactNode, useState } from 'react';
|
||||||
|
|
||||||
|
import { plural } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react/macro';
|
import { useLingui } from '@lingui/react/macro';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { EnvelopeType } from '@prisma/client';
|
import { EnvelopeType } from '@prisma/client';
|
||||||
@@ -115,7 +116,9 @@ export const EnvelopeDropZoneWrapper = ({
|
|||||||
? formatDocumentsPath(team.url)
|
? formatDocumentsPath(team.url)
|
||||||
: formatTemplatesPath(team.url);
|
: formatTemplatesPath(team.url);
|
||||||
|
|
||||||
await navigate(`${pathPrefix}/${id}/edit`);
|
const aiQueryParam = team.preferences.aiFeaturesEnabled ? '?ai=true' : '';
|
||||||
|
|
||||||
|
await navigate(`${pathPrefix}/${id}/edit${aiQueryParam}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const error = AppError.parseError(err);
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
@@ -153,7 +156,10 @@ export const EnvelopeDropZoneWrapper = ({
|
|||||||
|
|
||||||
if (maxItemsReached) {
|
if (maxItemsReached) {
|
||||||
toast({
|
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,
|
duration: 5000,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
@@ -220,9 +226,9 @@ export const EnvelopeDropZoneWrapper = ({
|
|||||||
{children}
|
{children}
|
||||||
|
|
||||||
{isDragActive && (
|
{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">
|
<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 ? (
|
{type === EnvelopeType.DOCUMENT ? (
|
||||||
<Trans>Upload Document</Trans>
|
<Trans>Upload Document</Trans>
|
||||||
) : (
|
) : (
|
||||||
@@ -230,7 +236,7 @@ export const EnvelopeDropZoneWrapper = ({
|
|||||||
)}
|
)}
|
||||||
</h2>
|
</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>
|
<Trans>Drag and drop your PDF file here</Trans>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -247,7 +253,7 @@ export const EnvelopeDropZoneWrapper = ({
|
|||||||
team?.id === undefined &&
|
team?.id === undefined &&
|
||||||
remaining.documents > 0 &&
|
remaining.documents > 0 &&
|
||||||
Number.isFinite(remaining.documents) && (
|
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>
|
<Trans>
|
||||||
{remaining.documents} of {quota.documents} documents remaining this month.
|
{remaining.documents} of {quota.documents} documents remaining this month.
|
||||||
</Trans>
|
</Trans>
|
||||||
@@ -258,10 +264,10 @@ export const EnvelopeDropZoneWrapper = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{isLoading && (
|
{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">
|
<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" />
|
<Loader className="h-12 w-12 animate-spin text-primary" />
|
||||||
<p className="text-foreground mt-8 font-medium">
|
<p className="mt-8 font-medium text-foreground">
|
||||||
<Trans>Uploading</Trans>
|
<Trans>Uploading</Trans>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg, plural } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react/macro';
|
import { useLingui } from '@lingui/react/macro';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { EnvelopeType } from '@prisma/client';
|
import { EnvelopeType } from '@prisma/client';
|
||||||
@@ -108,7 +108,9 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
|
|||||||
? formatDocumentsPath(team.url)
|
? formatDocumentsPath(team.url)
|
||||||
: formatTemplatesPath(team.url);
|
: formatTemplatesPath(team.url);
|
||||||
|
|
||||||
await navigate(`${pathPrefix}/${id}/edit`);
|
const aiQueryParam = team.preferences.aiFeaturesEnabled ? '?ai=true' : '';
|
||||||
|
|
||||||
|
await navigate(`${pathPrefix}/${id}/edit${aiQueryParam}`);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: type === EnvelopeType.DOCUMENT ? t`Document uploaded` : t`Template uploaded`,
|
title: type === EnvelopeType.DOCUMENT ? t`Document uploaded` : t`Template uploaded`,
|
||||||
@@ -153,7 +155,10 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
|
|||||||
|
|
||||||
if (maxItemsReached) {
|
if (maxItemsReached) {
|
||||||
toast({
|
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,
|
duration: 5000,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useLingui } from '@lingui/react/macro';
|
import { useLingui } from '@lingui/react/macro';
|
||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
|
import { useLoaderData } from 'react-router';
|
||||||
|
|
||||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
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 { DocumentSignatureType } from '@documenso/lib/constants/document';
|
||||||
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
|
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
@@ -19,9 +21,16 @@ export function meta() {
|
|||||||
return appMetaTags('Document Preferences');
|
return appMetaTags('Document Preferences');
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function OrganisationSettingsDocumentPage() {
|
export const loader = () => {
|
||||||
const { organisations } = useSession();
|
return {
|
||||||
|
isAiFeaturesConfigured: IS_AI_FEATURES_CONFIGURED(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function OrganisationSettingsDocumentPage() {
|
||||||
|
const { isAiFeaturesConfigured } = useLoaderData<typeof loader>();
|
||||||
|
|
||||||
|
const { organisations } = useSession();
|
||||||
const organisation = useCurrentOrganisation();
|
const organisation = useCurrentOrganisation();
|
||||||
|
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
@@ -48,6 +57,7 @@ export default function OrganisationSettingsDocumentPage() {
|
|||||||
includeSigningCertificate,
|
includeSigningCertificate,
|
||||||
includeAuditLog,
|
includeAuditLog,
|
||||||
signatureTypes,
|
signatureTypes,
|
||||||
|
aiFeaturesEnabled,
|
||||||
} = data;
|
} = data;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -56,7 +66,8 @@ export default function OrganisationSettingsDocumentPage() {
|
|||||||
documentDateFormat === null ||
|
documentDateFormat === null ||
|
||||||
includeSenderDetails === null ||
|
includeSenderDetails === null ||
|
||||||
includeSigningCertificate === null ||
|
includeSigningCertificate === null ||
|
||||||
includeAuditLog === null
|
includeAuditLog === null ||
|
||||||
|
aiFeaturesEnabled === null
|
||||||
) {
|
) {
|
||||||
throw new Error('Should not be possible.');
|
throw new Error('Should not be possible.');
|
||||||
}
|
}
|
||||||
@@ -74,6 +85,7 @@ export default function OrganisationSettingsDocumentPage() {
|
|||||||
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||||
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||||
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||||
|
aiFeaturesEnabled,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -93,7 +105,7 @@ export default function OrganisationSettingsDocumentPage() {
|
|||||||
if (isLoadingOrganisation || !organisationWithSettings) {
|
if (isLoadingOrganisation || !organisationWithSettings) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center rounded-lg py-32">
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -110,6 +122,7 @@ export default function OrganisationSettingsDocumentPage() {
|
|||||||
<section>
|
<section>
|
||||||
<DocumentPreferencesForm
|
<DocumentPreferencesForm
|
||||||
canInherit={false}
|
canInherit={false}
|
||||||
|
isAiFeaturesConfigured={isAiFeaturesConfigured}
|
||||||
settings={organisationWithSettings.organisationGlobalSettings}
|
settings={organisationWithSettings.organisationGlobalSettings}
|
||||||
onFormSubmit={onDocumentPreferencesFormSubmit}
|
onFormSubmit={onDocumentPreferencesFormSubmit}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useLingui } from '@lingui/react/macro';
|
import { useLingui } from '@lingui/react/macro';
|
||||||
import { Loader } from 'lucide-react';
|
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 { DocumentSignatureType } from '@documenso/lib/constants/document';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
@@ -17,7 +19,17 @@ export function meta() {
|
|||||||
return appMetaTags('Document Preferences');
|
return appMetaTags('Document Preferences');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const loader = () => {
|
||||||
|
return {
|
||||||
|
isAiFeaturesConfigured: IS_AI_FEATURES_CONFIGURED(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export default function TeamsSettingsPage() {
|
export default function TeamsSettingsPage() {
|
||||||
|
const { isAiFeaturesConfigured } = useLoaderData<typeof loader>();
|
||||||
|
|
||||||
|
console.log('isAiFeaturesConfigured', isAiFeaturesConfigured);
|
||||||
|
|
||||||
const team = useCurrentTeam();
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
@@ -40,6 +52,7 @@ export default function TeamsSettingsPage() {
|
|||||||
includeSigningCertificate,
|
includeSigningCertificate,
|
||||||
includeAuditLog,
|
includeAuditLog,
|
||||||
signatureTypes,
|
signatureTypes,
|
||||||
|
aiFeaturesEnabled,
|
||||||
} = data;
|
} = data;
|
||||||
|
|
||||||
await updateTeamSettings({
|
await updateTeamSettings({
|
||||||
@@ -52,6 +65,7 @@ export default function TeamsSettingsPage() {
|
|||||||
includeSenderDetails,
|
includeSenderDetails,
|
||||||
includeSigningCertificate,
|
includeSigningCertificate,
|
||||||
includeAuditLog,
|
includeAuditLog,
|
||||||
|
aiFeaturesEnabled,
|
||||||
...(signatureTypes.length === 0
|
...(signatureTypes.length === 0
|
||||||
? {
|
? {
|
||||||
typedSignatureEnabled: null,
|
typedSignatureEnabled: null,
|
||||||
@@ -82,7 +96,7 @@ export default function TeamsSettingsPage() {
|
|||||||
if (isLoadingTeam || !teamWithSettings) {
|
if (isLoadingTeam || !teamWithSettings) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center rounded-lg py-32">
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -97,6 +111,7 @@ export default function TeamsSettingsPage() {
|
|||||||
<section>
|
<section>
|
||||||
<DocumentPreferencesForm
|
<DocumentPreferencesForm
|
||||||
canInherit={true}
|
canInherit={true}
|
||||||
|
isAiFeaturesConfigured={isAiFeaturesConfigured}
|
||||||
settings={teamWithSettings.teamSettings}
|
settings={teamWithSettings.teamSettings}
|
||||||
onFormSubmit={onDocumentPreferencesSubmit}
|
onFormSubmit={onDocumentPreferencesSubmit}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { AlertTriangle, Building2, Database, Eye, Settings, UserCircle2 } from 'lucide-react';
|
import { AlertTriangle, Building2, Database, Eye, Settings, UserCircle2 } from 'lucide-react';
|
||||||
import { data, isRouteErrorResponse } from 'react-router';
|
import { data, isRouteErrorResponse } from 'react-router';
|
||||||
@@ -125,6 +126,7 @@ export async function loader({ params }: Route.LoaderArgs) {
|
|||||||
export default function OrganisationSsoConfirmationTokenPage({ loaderData }: Route.ComponentProps) {
|
export default function OrganisationSsoConfirmationTokenPage({ loaderData }: Route.ComponentProps) {
|
||||||
const { token, type, user, organisation } = loaderData;
|
const { token, type, user, organisation } = loaderData;
|
||||||
|
|
||||||
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
@@ -136,12 +138,12 @@ export default function OrganisationSsoConfirmationTokenPage({ loaderData }: Rou
|
|||||||
await navigate('/');
|
await navigate('/');
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Account link declined',
|
title: _(msg`Account link declined`),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast({
|
toast({
|
||||||
title: 'Error declining account link',
|
title: _(msg`Error declining account link`),
|
||||||
description: error.message,
|
description: error.message,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -153,12 +155,12 @@ export default function OrganisationSsoConfirmationTokenPage({ loaderData }: Rou
|
|||||||
await navigate(formatOrganisationLoginPath(organisation.url));
|
await navigate(formatOrganisationLoginPath(organisation.url));
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Account linked successfully',
|
title: _(msg`Account linked successfully`),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast({
|
toast({
|
||||||
title: 'Error linking account',
|
title: _(msg`Error linking account`),
|
||||||
description: error.message,
|
description: error.message,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -108,5 +108,5 @@
|
|||||||
"vite-plugin-babel-macros": "^1.0.6",
|
"vite-plugin-babel-macros": "^1.0.6",
|
||||||
"vite-tsconfig-paths": "^5.1.4"
|
"vite-tsconfig-paths": "^5.1.4"
|
||||||
},
|
},
|
||||||
"version": "2.1.0"
|
"version": "2.2.2"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -12,9 +12,11 @@ import { API_V2_BETA_URL, API_V2_URL } from '@documenso/lib/constants/app';
|
|||||||
import { jobsClient } from '@documenso/lib/jobs/client';
|
import { jobsClient } from '@documenso/lib/jobs/client';
|
||||||
import { TelemetryClient } from '@documenso/lib/server-only/telemetry/telemetry-client';
|
import { TelemetryClient } from '@documenso/lib/server-only/telemetry/telemetry-client';
|
||||||
import { getIpAddress } from '@documenso/lib/universal/get-ip-address';
|
import { getIpAddress } from '@documenso/lib/universal/get-ip-address';
|
||||||
|
import { env } from '@documenso/lib/utils/env';
|
||||||
import { logger } from '@documenso/lib/utils/logger';
|
import { logger } from '@documenso/lib/utils/logger';
|
||||||
import { openApiDocument } from '@documenso/trpc/server/open-api';
|
import { openApiDocument } from '@documenso/trpc/server/open-api';
|
||||||
|
|
||||||
|
import { aiRoute } from './api/ai/route';
|
||||||
import { downloadRoute } from './api/download/download';
|
import { downloadRoute } from './api/download/download';
|
||||||
import { filesRoute } from './api/files/files';
|
import { filesRoute } from './api/files/files';
|
||||||
import { type AppContext, appContext } from './context';
|
import { type AppContext, appContext } from './context';
|
||||||
@@ -50,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.
|
* Attach session and context to requests.
|
||||||
*/
|
*/
|
||||||
@@ -85,6 +102,10 @@ app.route('/api/auth', auth);
|
|||||||
// Files route.
|
// Files route.
|
||||||
app.route('/api/files', filesRoute);
|
app.route('/api/files', filesRoute);
|
||||||
|
|
||||||
|
// AI route.
|
||||||
|
app.use('/api/ai/*', aiRateLimitMiddleware);
|
||||||
|
app.route('/api/ai', aiRoute);
|
||||||
|
|
||||||
// API servers.
|
// API servers.
|
||||||
app.use(`/api/v1/*`, cors());
|
app.use(`/api/v1/*`, cors());
|
||||||
app.route('/api/v1', tsRestHonoApp);
|
app.route('/api/v1', tsRestHonoApp);
|
||||||
@@ -115,6 +136,8 @@ app.use(`${API_V2_BETA_URL}/*`, async (c) =>
|
|||||||
|
|
||||||
// Start telemetry client for anonymous usage tracking.
|
// Start telemetry client for anonymous usage tracking.
|
||||||
// Can be disabled by setting DOCUMENSO_DISABLE_TELEMETRY=true
|
// Can be disabled by setting DOCUMENSO_DISABLE_TELEMETRY=true
|
||||||
void TelemetryClient.start();
|
if (env('NODE_ENV') !== 'development') {
|
||||||
|
void TelemetryClient.start();
|
||||||
|
}
|
||||||
|
|
||||||
export default app;
|
export default app;
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export default defineConfig({
|
|||||||
ssr: {
|
ssr: {
|
||||||
noExternal: ['react-dropzone', 'plausible-tracker'],
|
noExternal: ['react-dropzone', 'plausible-tracker'],
|
||||||
external: [
|
external: [
|
||||||
|
'@napi-rs/canvas',
|
||||||
'@node-rs/bcrypt',
|
'@node-rs/bcrypt',
|
||||||
'@prisma/client',
|
'@prisma/client',
|
||||||
'@documenso/tailwind-config',
|
'@documenso/tailwind-config',
|
||||||
@@ -64,6 +65,7 @@ export default defineConfig({
|
|||||||
include: ['prop-types', 'file-selector', 'attr-accept'],
|
include: ['prop-types', 'file-selector', 'attr-accept'],
|
||||||
exclude: [
|
exclude: [
|
||||||
'node_modules',
|
'node_modules',
|
||||||
|
'@napi-rs/canvas',
|
||||||
'@node-rs/bcrypt',
|
'@node-rs/bcrypt',
|
||||||
'@documenso/pdf-sign',
|
'@documenso/pdf-sign',
|
||||||
'sharp',
|
'sharp',
|
||||||
@@ -94,6 +96,7 @@ export default defineConfig({
|
|||||||
build: {
|
build: {
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
external: [
|
external: [
|
||||||
|
'@napi-rs/canvas',
|
||||||
'@node-rs/bcrypt',
|
'@node-rs/bcrypt',
|
||||||
'@documenso/pdf-sign',
|
'@documenso/pdf-sign',
|
||||||
'@aws-sdk/cloudfront-signer',
|
'@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/out/package-lock.json ./package-lock.json
|
||||||
|
|
||||||
COPY --from=builder /app/lingui.config.ts ./lingui.config.ts
|
COPY --from=builder /app/lingui.config.ts ./lingui.config.ts
|
||||||
|
COPY --from=builder /app/patches ./patches
|
||||||
|
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
@@ -108,6 +109,8 @@ WORKDIR /app
|
|||||||
COPY --from=builder --chown=nodejs:nodejs /app/out/json/ .
|
COPY --from=builder --chown=nodejs:nodejs /app/out/json/ .
|
||||||
# Copy the tailwind config files across
|
# Copy the tailwind config files across
|
||||||
COPY --from=builder --chown=nodejs:nodejs /app/out/full/packages/tailwind-config ./packages/tailwind-config
|
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
|
RUN npm ci --only=production
|
||||||
|
|
||||||
|
|||||||
Generated
+651
-231
File diff suppressed because it is too large
Load Diff
+7
-3
@@ -5,8 +5,9 @@
|
|||||||
"apps/*",
|
"apps/*",
|
||||||
"packages/*"
|
"packages/*"
|
||||||
],
|
],
|
||||||
"version": "2.1.0",
|
"version": "2.2.2",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"postinstall": "patch-package",
|
||||||
"build": "turbo run build",
|
"build": "turbo run build",
|
||||||
"dev": "turbo run dev --filter=@documenso/remix",
|
"dev": "turbo run dev --filter=@documenso/remix",
|
||||||
"dev:remix": "turbo run dev --filter=@documenso/remix",
|
"dev:remix": "turbo run dev --filter=@documenso/remix",
|
||||||
@@ -63,7 +64,7 @@
|
|||||||
"lint-staged": "^16.2.7",
|
"lint-staged": "^16.2.7",
|
||||||
"nanoid": "^5.1.6",
|
"nanoid": "^5.1.6",
|
||||||
"nodemailer": "^7.0.10",
|
"nodemailer": "^7.0.10",
|
||||||
"pdfjs-dist": "5.4.296",
|
"pdfjs-dist": "5.4.449",
|
||||||
"pino": "^9.14.0",
|
"pino": "^9.14.0",
|
||||||
"pino-pretty": "^13.1.2",
|
"pino-pretty": "^13.1.2",
|
||||||
"playwright": "1.56.1",
|
"playwright": "1.56.1",
|
||||||
@@ -83,19 +84,22 @@
|
|||||||
"zod-prisma-types": "3.3.5"
|
"zod-prisma-types": "3.3.5"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ai-sdk/google-vertex": "3.0.81",
|
||||||
"@documenso/pdf-sign": "^0.1.0",
|
"@documenso/pdf-sign": "^0.1.0",
|
||||||
"@documenso/prisma": "*",
|
"@documenso/prisma": "*",
|
||||||
"@lingui/conf": "^5.6.0",
|
"@lingui/conf": "^5.6.0",
|
||||||
"@lingui/core": "^5.6.0",
|
"@lingui/core": "^5.6.0",
|
||||||
|
"ai": "^5.0.104",
|
||||||
"inngest-cli": "^1.13.7",
|
"inngest-cli": "^1.13.7",
|
||||||
"luxon": "^3.7.2",
|
"luxon": "^3.7.2",
|
||||||
|
"patch-package": "^8.0.1",
|
||||||
"posthog-node": "4.18.0",
|
"posthog-node": "4.18.0",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"typescript": "5.6.2",
|
"typescript": "5.6.2",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"pdfjs-dist": "5.4.296",
|
"pdfjs-dist": "5.4.449",
|
||||||
"typescript": "5.6.2",
|
"typescript": "5.6.2",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { pick } from 'remeda';
|
|||||||
|
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||||
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
|
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import {
|
import {
|
||||||
DocumentDistributionMethod,
|
DocumentDistributionMethod,
|
||||||
@@ -24,9 +23,7 @@ import type {
|
|||||||
TCreateEnvelopePayload,
|
TCreateEnvelopePayload,
|
||||||
TCreateEnvelopeResponse,
|
TCreateEnvelopeResponse,
|
||||||
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
|
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
|
||||||
import type { TDistributeEnvelopeRequest } from '@documenso/trpc/server/envelope-router/distribute-envelope.types';
|
|
||||||
import type { TCreateEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/envelope-recipients/create-envelope-recipients.types';
|
import type { TCreateEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/envelope-recipients/create-envelope-recipients.types';
|
||||||
import type { TUpdateEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/envelope-recipients/update-envelope-recipients.types';
|
|
||||||
import type { TGetEnvelopeResponse } from '@documenso/trpc/server/envelope-router/get-envelope.types';
|
import type { TGetEnvelopeResponse } from '@documenso/trpc/server/envelope-router/get-envelope.types';
|
||||||
import type { TUpdateEnvelopeRequest } from '@documenso/trpc/server/envelope-router/update-envelope.types';
|
import type { TUpdateEnvelopeRequest } from '@documenso/trpc/server/envelope-router/update-envelope.types';
|
||||||
|
|
||||||
@@ -560,543 +557,4 @@ test.describe('API V2 Envelopes', () => {
|
|||||||
userEmail: userA.email,
|
userEmail: userA.email,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe('Empty recipient tests', () => {
|
|
||||||
test('Create template envelope with empty email recipient', async ({ request }) => {
|
|
||||||
const payload = {
|
|
||||||
type: EnvelopeType.TEMPLATE,
|
|
||||||
title: 'Template with Empty Email Recipient',
|
|
||||||
} satisfies TCreateEnvelopePayload;
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('payload', JSON.stringify(payload));
|
|
||||||
|
|
||||||
const files = [
|
|
||||||
{
|
|
||||||
name: 'example.pdf',
|
|
||||||
data: fs.readFileSync(path.join(__dirname, '../../../../../assets/example.pdf')),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
formData.append('files', new File([file.data], file.name, { type: 'application/pdf' }));
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await request.post(`${baseUrl}/envelope/create`, {
|
|
||||||
headers: { Authorization: `Bearer ${tokenA}` },
|
|
||||||
multipart: formData,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.ok()).toBeTruthy();
|
|
||||||
expect(res.status()).toBe(200);
|
|
||||||
|
|
||||||
const response = (await res.json()) as TCreateEnvelopeResponse;
|
|
||||||
|
|
||||||
// Create recipient with empty email
|
|
||||||
const createRecipientsRequest: TCreateEnvelopeRecipientsRequest = {
|
|
||||||
envelopeId: response.id,
|
|
||||||
data: [
|
|
||||||
{
|
|
||||||
email: '',
|
|
||||||
name: 'Test Recipient',
|
|
||||||
role: RecipientRole.SIGNER,
|
|
||||||
accessAuth: [],
|
|
||||||
actionAuth: [],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const createRecipientsRes = await request.post(`${baseUrl}/envelope/recipient/create-many`, {
|
|
||||||
headers: { Authorization: `Bearer ${tokenA}` },
|
|
||||||
data: createRecipientsRequest,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(createRecipientsRes.ok()).toBeTruthy();
|
|
||||||
expect(createRecipientsRes.status()).toBe(200);
|
|
||||||
|
|
||||||
const recipientsResponse = await createRecipientsRes.json();
|
|
||||||
const recipient = recipientsResponse.data[0];
|
|
||||||
|
|
||||||
expect(recipient.email).toBe('');
|
|
||||||
expect(recipient.name).toBe('Test Recipient');
|
|
||||||
|
|
||||||
// Get envelope items to assign fields
|
|
||||||
const getEnvelopeRes = await request.get(`${baseUrl}/envelope/${response.id}`, {
|
|
||||||
headers: { Authorization: `Bearer ${tokenA}` },
|
|
||||||
});
|
|
||||||
|
|
||||||
const envelope: TGetEnvelopeResponse = await getEnvelopeRes.json();
|
|
||||||
const envelopeItem = envelope.envelopeItems[0];
|
|
||||||
|
|
||||||
// Create field for the recipient with empty email
|
|
||||||
const createFieldsRequest = {
|
|
||||||
envelopeId: response.id,
|
|
||||||
data: [
|
|
||||||
{
|
|
||||||
recipientId: recipient.id,
|
|
||||||
envelopeItemId: envelopeItem.id,
|
|
||||||
type: FieldType.SIGNATURE,
|
|
||||||
page: 1,
|
|
||||||
positionX: 100,
|
|
||||||
positionY: 100,
|
|
||||||
width: 50,
|
|
||||||
height: 50,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const createFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, {
|
|
||||||
headers: { Authorization: `Bearer ${tokenA}` },
|
|
||||||
data: createFieldsRequest,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(createFieldsRes.ok()).toBeTruthy();
|
|
||||||
expect(createFieldsRes.status()).toBe(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Create document envelope with empty email recipient', async ({ request }) => {
|
|
||||||
const payload = {
|
|
||||||
type: EnvelopeType.DOCUMENT,
|
|
||||||
title: 'Document with Empty Email Recipient',
|
|
||||||
} satisfies TCreateEnvelopePayload;
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('payload', JSON.stringify(payload));
|
|
||||||
|
|
||||||
const files = [
|
|
||||||
{
|
|
||||||
name: 'example.pdf',
|
|
||||||
data: fs.readFileSync(path.join(__dirname, '../../../../../assets/example.pdf')),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
formData.append('files', new File([file.data], file.name, { type: 'application/pdf' }));
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await request.post(`${baseUrl}/envelope/create`, {
|
|
||||||
headers: { Authorization: `Bearer ${tokenA}` },
|
|
||||||
multipart: formData,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.ok()).toBeTruthy();
|
|
||||||
expect(res.status()).toBe(200);
|
|
||||||
|
|
||||||
const response = (await res.json()) as TCreateEnvelopeResponse;
|
|
||||||
|
|
||||||
// Create recipient with empty email
|
|
||||||
const createRecipientsRequest: TCreateEnvelopeRecipientsRequest = {
|
|
||||||
envelopeId: response.id,
|
|
||||||
data: [
|
|
||||||
{
|
|
||||||
email: '',
|
|
||||||
name: 'Document Recipient No Email',
|
|
||||||
role: RecipientRole.SIGNER,
|
|
||||||
accessAuth: [],
|
|
||||||
actionAuth: [],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const createRecipientsRes = await request.post(`${baseUrl}/envelope/recipient/create-many`, {
|
|
||||||
headers: { Authorization: `Bearer ${tokenA}` },
|
|
||||||
data: createRecipientsRequest,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(createRecipientsRes.ok()).toBeTruthy();
|
|
||||||
const recipientsResponse = await createRecipientsRes.json();
|
|
||||||
const recipient = recipientsResponse.data[0];
|
|
||||||
|
|
||||||
expect(recipient.email).toBe('');
|
|
||||||
expect(recipient.name).toBe('Document Recipient No Email');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Update recipient to have empty email', async ({ request }) => {
|
|
||||||
const payload = {
|
|
||||||
type: EnvelopeType.TEMPLATE,
|
|
||||||
title: 'Update Recipient Email Test',
|
|
||||||
recipients: [
|
|
||||||
{
|
|
||||||
email: userA.email,
|
|
||||||
name: 'Test User',
|
|
||||||
role: RecipientRole.SIGNER,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
} satisfies TCreateEnvelopePayload;
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('payload', JSON.stringify(payload));
|
|
||||||
|
|
||||||
const files = [
|
|
||||||
{
|
|
||||||
name: 'example.pdf',
|
|
||||||
data: fs.readFileSync(path.join(__dirname, '../../../../../assets/example.pdf')),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
formData.append('files', new File([file.data], file.name, { type: 'application/pdf' }));
|
|
||||||
}
|
|
||||||
|
|
||||||
const createRes = await request.post(`${baseUrl}/envelope/create`, {
|
|
||||||
headers: { Authorization: `Bearer ${tokenA}` },
|
|
||||||
multipart: formData,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(createRes.ok()).toBeTruthy();
|
|
||||||
const createResponse = (await createRes.json()) as TCreateEnvelopeResponse;
|
|
||||||
|
|
||||||
// Get the envelope to get recipient ID
|
|
||||||
const getRes = await request.get(`${baseUrl}/envelope/${createResponse.id}`, {
|
|
||||||
headers: { Authorization: `Bearer ${tokenA}` },
|
|
||||||
});
|
|
||||||
|
|
||||||
const envelope: TGetEnvelopeResponse = await getRes.json();
|
|
||||||
const recipientId = envelope.recipients[0].id;
|
|
||||||
|
|
||||||
// Update recipient to have empty email
|
|
||||||
const updateRequest: TUpdateEnvelopeRecipientsRequest = {
|
|
||||||
envelopeId: createResponse.id,
|
|
||||||
data: [
|
|
||||||
{
|
|
||||||
id: recipientId,
|
|
||||||
email: '',
|
|
||||||
name: 'Updated Name No Email',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateRes = await request.post(`${baseUrl}/envelope/recipient/update-many`, {
|
|
||||||
headers: { Authorization: `Bearer ${tokenA}` },
|
|
||||||
data: updateRequest,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(updateRes.ok()).toBeTruthy();
|
|
||||||
const updateResponse = await updateRes.json();
|
|
||||||
const updatedRecipient = updateResponse.data[0];
|
|
||||||
|
|
||||||
expect(updatedRecipient.email).toBe('');
|
|
||||||
expect(updatedRecipient.name).toBe('Updated Name No Email');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Mixed recipients with and without emails', async ({ request }) => {
|
|
||||||
const payload = {
|
|
||||||
type: EnvelopeType.TEMPLATE,
|
|
||||||
title: 'Mixed Recipients Test',
|
|
||||||
} satisfies TCreateEnvelopePayload;
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('payload', JSON.stringify(payload));
|
|
||||||
|
|
||||||
const files = [
|
|
||||||
{
|
|
||||||
name: 'example.pdf',
|
|
||||||
data: fs.readFileSync(path.join(__dirname, '../../../../../assets/example.pdf')),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
formData.append('files', new File([file.data], file.name, { type: 'application/pdf' }));
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await request.post(`${baseUrl}/envelope/create`, {
|
|
||||||
headers: { Authorization: `Bearer ${tokenA}` },
|
|
||||||
multipart: formData,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.ok()).toBeTruthy();
|
|
||||||
const response = (await res.json()) as TCreateEnvelopeResponse;
|
|
||||||
|
|
||||||
// Create multiple recipients, some with email, some without
|
|
||||||
const createRecipientsRequest: TCreateEnvelopeRecipientsRequest = {
|
|
||||||
envelopeId: response.id,
|
|
||||||
data: [
|
|
||||||
{
|
|
||||||
email: userA.email,
|
|
||||||
name: 'Recipient With Email',
|
|
||||||
role: RecipientRole.SIGNER,
|
|
||||||
accessAuth: [],
|
|
||||||
actionAuth: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
email: '',
|
|
||||||
name: 'Recipient Without Email 1',
|
|
||||||
role: RecipientRole.SIGNER,
|
|
||||||
accessAuth: [],
|
|
||||||
actionAuth: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
email: userB.email,
|
|
||||||
name: 'Another With Email',
|
|
||||||
role: RecipientRole.APPROVER,
|
|
||||||
accessAuth: [],
|
|
||||||
actionAuth: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
email: '',
|
|
||||||
name: 'Recipient Without Email 2',
|
|
||||||
role: RecipientRole.SIGNER,
|
|
||||||
accessAuth: [],
|
|
||||||
actionAuth: [],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const createRecipientsRes = await request.post(`${baseUrl}/envelope/recipient/create-many`, {
|
|
||||||
headers: { Authorization: `Bearer ${tokenA}` },
|
|
||||||
data: createRecipientsRequest,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(createRecipientsRes.ok()).toBeTruthy();
|
|
||||||
const recipientsResponse = await createRecipientsRes.json();
|
|
||||||
const recipients = recipientsResponse.data;
|
|
||||||
|
|
||||||
expect(recipients.length).toBe(4);
|
|
||||||
expect(recipients[0].email).toBe(userA.email.toLowerCase());
|
|
||||||
expect(recipients[1].email).toBe('');
|
|
||||||
expect(recipients[2].email).toBe(userB.email.toLowerCase());
|
|
||||||
expect(recipients[3].email).toBe('');
|
|
||||||
|
|
||||||
// Get envelope to assign fields
|
|
||||||
const getEnvelopeRes = await request.get(`${baseUrl}/envelope/${response.id}`, {
|
|
||||||
headers: { Authorization: `Bearer ${tokenA}` },
|
|
||||||
});
|
|
||||||
|
|
||||||
const envelope: TGetEnvelopeResponse = await getEnvelopeRes.json();
|
|
||||||
const envelopeItem = envelope.envelopeItems[0];
|
|
||||||
|
|
||||||
// Create fields for all recipients including those without emails
|
|
||||||
const createFieldsRequest = {
|
|
||||||
envelopeId: response.id,
|
|
||||||
data: recipients.map((recipient, index) => ({
|
|
||||||
recipientId: recipient.id,
|
|
||||||
envelopeItemId: envelopeItem.id,
|
|
||||||
type: FieldType.SIGNATURE,
|
|
||||||
page: 1,
|
|
||||||
positionX: 100,
|
|
||||||
positionY: 0 + index,
|
|
||||||
width: 50,
|
|
||||||
height: 50,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
|
|
||||||
const createFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, {
|
|
||||||
headers: { Authorization: `Bearer ${tokenA}` },
|
|
||||||
data: createFieldsRequest,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(createFieldsRes.ok()).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Distribute envelope with empty email recipients', async ({ request }) => {
|
|
||||||
const payload = {
|
|
||||||
type: EnvelopeType.DOCUMENT,
|
|
||||||
title: 'Document for Distribution with Empty Email',
|
|
||||||
} satisfies TCreateEnvelopePayload;
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('payload', JSON.stringify(payload));
|
|
||||||
|
|
||||||
const files = [
|
|
||||||
{
|
|
||||||
name: 'example.pdf',
|
|
||||||
data: fs.readFileSync(path.join(__dirname, '../../../../../assets/example.pdf')),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
formData.append('files', new File([file.data], file.name, { type: 'application/pdf' }));
|
|
||||||
}
|
|
||||||
|
|
||||||
const createRes = await request.post(`${baseUrl}/envelope/create`, {
|
|
||||||
headers: { Authorization: `Bearer ${tokenA}` },
|
|
||||||
multipart: formData,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(createRes.ok()).toBeTruthy();
|
|
||||||
const createResponse = (await createRes.json()) as TCreateEnvelopeResponse;
|
|
||||||
|
|
||||||
// Create recipients with empty emails
|
|
||||||
const createRecipientsRequest: TCreateEnvelopeRecipientsRequest = {
|
|
||||||
envelopeId: createResponse.id,
|
|
||||||
data: [
|
|
||||||
{
|
|
||||||
email: '',
|
|
||||||
name: 'Recipient One',
|
|
||||||
role: RecipientRole.SIGNER,
|
|
||||||
accessAuth: [],
|
|
||||||
actionAuth: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
email: '',
|
|
||||||
name: 'Recipient Two',
|
|
||||||
role: RecipientRole.APPROVER,
|
|
||||||
accessAuth: [],
|
|
||||||
actionAuth: [],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const createRecipientsRes = await request.post(`${baseUrl}/envelope/recipient/create-many`, {
|
|
||||||
headers: { Authorization: `Bearer ${tokenA}` },
|
|
||||||
data: createRecipientsRequest,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(createRecipientsRes.ok()).toBeTruthy();
|
|
||||||
const recipientsResponse = await createRecipientsRes.json();
|
|
||||||
const recipients = recipientsResponse.data;
|
|
||||||
|
|
||||||
// Get envelope to assign fields
|
|
||||||
const getEnvelopeRes = await request.get(`${baseUrl}/envelope/${createResponse.id}`, {
|
|
||||||
headers: { Authorization: `Bearer ${tokenA}` },
|
|
||||||
});
|
|
||||||
|
|
||||||
const envelope: TGetEnvelopeResponse = await getEnvelopeRes.json();
|
|
||||||
const envelopeItem = envelope.envelopeItems[0];
|
|
||||||
|
|
||||||
// Create fields for recipients
|
|
||||||
const createFieldsRequest = {
|
|
||||||
envelopeId: createResponse.id,
|
|
||||||
data: recipients.map((recipient, index) => ({
|
|
||||||
recipientId: recipient.id,
|
|
||||||
envelopeItemId: envelopeItem.id,
|
|
||||||
type: FieldType.SIGNATURE,
|
|
||||||
page: 1,
|
|
||||||
positionX: 100,
|
|
||||||
positionY: 0 + index,
|
|
||||||
width: 50,
|
|
||||||
height: 50,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
|
|
||||||
const createFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, {
|
|
||||||
headers: { Authorization: `Bearer ${tokenA}` },
|
|
||||||
data: createFieldsRequest,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(createFieldsRes.ok()).toBeTruthy();
|
|
||||||
|
|
||||||
// Distribute the envelope
|
|
||||||
const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, {
|
|
||||||
headers: { Authorization: `Bearer ${tokenA}` },
|
|
||||||
data: {
|
|
||||||
envelopeId: createResponse.id,
|
|
||||||
} satisfies TDistributeEnvelopeRequest,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(distributeRes.ok()).toBeTruthy();
|
|
||||||
expect(distributeRes.status()).toBe(200);
|
|
||||||
|
|
||||||
const distributeResponse = await distributeRes.json();
|
|
||||||
expect(distributeResponse.success).toBe(true);
|
|
||||||
expect(distributeResponse.id).toBe(createResponse.id);
|
|
||||||
expect(distributeResponse.recipients).toHaveLength(2);
|
|
||||||
|
|
||||||
// Verify recipients have empty emails and signing URLs
|
|
||||||
expect(distributeResponse.recipients[0].email).toBe('');
|
|
||||||
expect(distributeResponse.recipients[0].signingUrl).toBeTruthy();
|
|
||||||
expect(distributeResponse.recipients[1].email).toBe('');
|
|
||||||
expect(distributeResponse.recipients[1].signingUrl).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Distribute envelope with empty email recipient and auth requirements fails', async ({
|
|
||||||
request,
|
|
||||||
}) => {
|
|
||||||
const payload = {
|
|
||||||
type: EnvelopeType.DOCUMENT,
|
|
||||||
title: 'Document with Auth Requirements',
|
|
||||||
} satisfies TCreateEnvelopePayload;
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('payload', JSON.stringify(payload));
|
|
||||||
|
|
||||||
const files = [
|
|
||||||
{
|
|
||||||
name: 'example.pdf',
|
|
||||||
data: fs.readFileSync(path.join(__dirname, '../../../../../assets/example.pdf')),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
formData.append('files', new File([file.data], file.name, { type: 'application/pdf' }));
|
|
||||||
}
|
|
||||||
|
|
||||||
const createRes = await request.post(`${baseUrl}/envelope/create`, {
|
|
||||||
headers: { Authorization: `Bearer ${tokenA}` },
|
|
||||||
multipart: formData,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(createRes.ok()).toBeTruthy();
|
|
||||||
const createResponse = (await createRes.json()) as TCreateEnvelopeResponse;
|
|
||||||
|
|
||||||
// Create recipient with empty email and TWO_FACTOR_AUTH action auth
|
|
||||||
const createRecipientsRequest: TCreateEnvelopeRecipientsRequest = {
|
|
||||||
envelopeId: createResponse.id,
|
|
||||||
data: [
|
|
||||||
{
|
|
||||||
email: '',
|
|
||||||
name: 'Recipient With Auth',
|
|
||||||
role: RecipientRole.SIGNER,
|
|
||||||
accessAuth: [DocumentAccessAuth.TWO_FACTOR_AUTH],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const createRecipientsRes = await request.post(`${baseUrl}/envelope/recipient/create-many`, {
|
|
||||||
headers: { Authorization: `Bearer ${tokenA}` },
|
|
||||||
data: createRecipientsRequest,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(createRecipientsRes.ok()).toBeTruthy();
|
|
||||||
const recipientsResponse = await createRecipientsRes.json();
|
|
||||||
const recipient = recipientsResponse.data[0];
|
|
||||||
|
|
||||||
// Get envelope to assign fields
|
|
||||||
const getEnvelopeRes = await request.get(`${baseUrl}/envelope/${createResponse.id}`, {
|
|
||||||
headers: { Authorization: `Bearer ${tokenA}` },
|
|
||||||
});
|
|
||||||
|
|
||||||
const envelope: TGetEnvelopeResponse = await getEnvelopeRes.json();
|
|
||||||
const envelopeItem = envelope.envelopeItems[0];
|
|
||||||
|
|
||||||
// Create field for the recipient
|
|
||||||
const createFieldsRequest = {
|
|
||||||
envelopeId: createResponse.id,
|
|
||||||
data: [
|
|
||||||
{
|
|
||||||
recipientId: recipient.id,
|
|
||||||
envelopeItemId: envelopeItem.id,
|
|
||||||
type: FieldType.SIGNATURE,
|
|
||||||
page: 1,
|
|
||||||
positionX: 100,
|
|
||||||
positionY: 100,
|
|
||||||
width: 50,
|
|
||||||
height: 50,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const createFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, {
|
|
||||||
headers: { Authorization: `Bearer ${tokenA}` },
|
|
||||||
data: createFieldsRequest,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(createFieldsRes.ok()).toBeTruthy();
|
|
||||||
|
|
||||||
// Try to distribute the envelope - should fail
|
|
||||||
const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, {
|
|
||||||
headers: { Authorization: `Bearer ${tokenA}` },
|
|
||||||
data: {
|
|
||||||
envelopeId: createResponse.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Expect distribution to fail
|
|
||||||
expect(distributeRes.ok()).toBeFalsy();
|
|
||||||
expect(distributeRes.status()).toBe(400);
|
|
||||||
|
|
||||||
const errorResponse = await distributeRes.json();
|
|
||||||
expect(errorResponse.message).toContain('requires an email');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@documenso/lib": "*",
|
"@documenso/lib": "*",
|
||||||
"@documenso/prisma": "*",
|
"@documenso/prisma": "*",
|
||||||
"@napi-rs/canvas": "^0.1.82",
|
"@napi-rs/canvas": "^0.1.83",
|
||||||
"@playwright/test": "1.56.1",
|
"@playwright/test": "1.56.1",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/pngjs": "^6.0.5",
|
"@types/pngjs": "^6.0.5",
|
||||||
|
|||||||
@@ -18,3 +18,6 @@ export const SUPPORT_EMAIL = env('NEXT_PUBLIC_SUPPORT_EMAIL') ?? 'support@docume
|
|||||||
|
|
||||||
export const USE_INTERNAL_URL_BROWSERLESS = () =>
|
export const USE_INTERNAL_URL_BROWSERLESS = () =>
|
||||||
env('NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS') === 'true';
|
env('NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS') === 'true';
|
||||||
|
|
||||||
|
export const IS_AI_FEATURES_CONFIGURED = () =>
|
||||||
|
!!env('GOOGLE_VERTEX_PROJECT_ID') && !!env('GOOGLE_VERTEX_API_KEY');
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { EnvelopeType, ReadStatus, SendStatus, SigningStatus } from '@prisma/cli
|
|||||||
|
|
||||||
import { mailer } from '@documenso/email/mailer';
|
import { mailer } from '@documenso/email/mailer';
|
||||||
import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
|
import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
|
||||||
import { isRecipientEmailValidForSending } from '@documenso/lib/utils/recipients';
|
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
|
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
|
||||||
@@ -78,8 +77,7 @@ export const run = async ({
|
|||||||
const recipientsToNotify = envelope.recipients.filter(
|
const recipientsToNotify = envelope.recipients.filter(
|
||||||
(recipient) =>
|
(recipient) =>
|
||||||
(recipient.sendStatus === SendStatus.SENT || recipient.readStatus === ReadStatus.OPENED) &&
|
(recipient.sendStatus === SendStatus.SENT || recipient.readStatus === ReadStatus.OPENED) &&
|
||||||
recipient.signingStatus !== SigningStatus.REJECTED &&
|
recipient.signingStatus !== SigningStatus.REJECTED,
|
||||||
isRecipientEmailValidForSending(recipient),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await io.runTask('send-cancellation-emails', async () => {
|
await io.runTask('send-cancellation-emails', async () => {
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
|
|||||||
import { getEmailContext } from '../../../server-only/email/get-email-context';
|
import { getEmailContext } from '../../../server-only/email/get-email-context';
|
||||||
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
|
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
|
||||||
import { unsafeBuildEnvelopeIdQuery } from '../../../utils/envelope';
|
import { unsafeBuildEnvelopeIdQuery } from '../../../utils/envelope';
|
||||||
import { isRecipientEmailValidForSending } from '../../../utils/recipients';
|
|
||||||
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
|
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
|
||||||
import type { JobRunIO } from '../../client/_internal/job';
|
import type { JobRunIO } from '../../client/_internal/job';
|
||||||
import type { TSendRecipientSignedEmailJobDefinition } from './send-recipient-signed-email';
|
import type { TSendRecipientSignedEmailJobDefinition } from './send-recipient-signed-email';
|
||||||
@@ -80,8 +79,8 @@ export const run = async ({
|
|||||||
|
|
||||||
const recipientReference = recipientName || recipientEmail;
|
const recipientReference = recipientName || recipientEmail;
|
||||||
|
|
||||||
// Don't send notification if the owner is the one who signed.
|
// Don't send notification if the owner is the one who signed
|
||||||
if (owner.email === recipientEmail || !isRecipientEmailValidForSending(recipient)) {
|
if (owner.email === recipientEmail) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { EnvelopeType, SendStatus, SigningStatus } from '@prisma/client';
|
|||||||
import { mailer } from '@documenso/email/mailer';
|
import { mailer } from '@documenso/email/mailer';
|
||||||
import DocumentRejectedEmail from '@documenso/email/templates/document-rejected';
|
import DocumentRejectedEmail from '@documenso/email/templates/document-rejected';
|
||||||
import DocumentRejectionConfirmedEmail from '@documenso/email/templates/document-rejection-confirmed';
|
import DocumentRejectionConfirmedEmail from '@documenso/email/templates/document-rejection-confirmed';
|
||||||
import { isRecipientEmailValidForSending } from '@documenso/lib/utils/recipients';
|
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
|
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
|
||||||
@@ -86,38 +85,36 @@ export const run = async ({
|
|||||||
const i18n = await getI18nInstance(emailLanguage);
|
const i18n = await getI18nInstance(emailLanguage);
|
||||||
|
|
||||||
// Send confirmation email to the recipient who rejected
|
// Send confirmation email to the recipient who rejected
|
||||||
if (isRecipientEmailValidForSending(recipient)) {
|
await io.runTask('send-rejection-confirmation-email', async () => {
|
||||||
await io.runTask('send-rejection-confirmation-email', async () => {
|
const recipientTemplate = createElement(DocumentRejectionConfirmedEmail, {
|
||||||
const recipientTemplate = createElement(DocumentRejectionConfirmedEmail, {
|
recipientName: recipient.name,
|
||||||
recipientName: recipient.name,
|
documentName: envelope.title,
|
||||||
documentName: envelope.title,
|
documentOwnerName: envelope.user.name || envelope.user.email,
|
||||||
documentOwnerName: envelope.user.name || envelope.user.email,
|
reason: recipient.rejectionReason || '',
|
||||||
reason: recipient.rejectionReason || '',
|
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
|
||||||
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const [html, text] = await Promise.all([
|
|
||||||
renderEmailWithI18N(recipientTemplate, { lang: emailLanguage, branding }),
|
|
||||||
renderEmailWithI18N(recipientTemplate, {
|
|
||||||
lang: emailLanguage,
|
|
||||||
branding,
|
|
||||||
plainText: true,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
await mailer.sendMail({
|
|
||||||
to: {
|
|
||||||
name: recipient.name,
|
|
||||||
address: recipient.email,
|
|
||||||
},
|
|
||||||
from: senderEmail,
|
|
||||||
replyTo: replyToEmail,
|
|
||||||
subject: i18n._(msg`Document "${envelope.title}" - Rejection Confirmed`),
|
|
||||||
html,
|
|
||||||
text,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
const [html, text] = await Promise.all([
|
||||||
|
renderEmailWithI18N(recipientTemplate, { lang: emailLanguage, branding }),
|
||||||
|
renderEmailWithI18N(recipientTemplate, {
|
||||||
|
lang: emailLanguage,
|
||||||
|
branding,
|
||||||
|
plainText: true,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await mailer.sendMail({
|
||||||
|
to: {
|
||||||
|
name: recipient.name,
|
||||||
|
address: recipient.email,
|
||||||
|
},
|
||||||
|
from: senderEmail,
|
||||||
|
replyTo: replyToEmail,
|
||||||
|
subject: i18n._(msg`Document "${envelope.title}" - Rejection Confirmed`),
|
||||||
|
html,
|
||||||
|
text,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Send notification email to document owner
|
// Send notification email to document owner
|
||||||
await io.runTask('send-owner-notification-email', async () => {
|
await io.runTask('send-owner-notification-email', async () => {
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
|
|
||||||
import { mailer } from '@documenso/email/mailer';
|
import { mailer } from '@documenso/email/mailer';
|
||||||
import DocumentInviteEmailTemplate from '@documenso/email/templates/document-invite';
|
import DocumentInviteEmailTemplate from '@documenso/email/templates/document-invite';
|
||||||
import { isRecipientEmailValidForSending } from '@documenso/lib/utils/recipients';
|
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
|
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
|
||||||
@@ -178,33 +177,31 @@ export const run = async ({
|
|||||||
includeSenderDetails: settings.includeSenderDetails,
|
includeSenderDetails: settings.includeSenderDetails,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isRecipientEmailValidForSending(recipient)) {
|
await io.runTask('send-signing-email', async () => {
|
||||||
await io.runTask('send-signing-email', async () => {
|
const [html, text] = await Promise.all([
|
||||||
const [html, text] = await Promise.all([
|
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
|
||||||
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
|
renderEmailWithI18N(template, {
|
||||||
renderEmailWithI18N(template, {
|
lang: emailLanguage,
|
||||||
lang: emailLanguage,
|
branding,
|
||||||
branding,
|
plainText: true,
|
||||||
plainText: true,
|
}),
|
||||||
}),
|
]);
|
||||||
]);
|
|
||||||
|
|
||||||
await mailer.sendMail({
|
await mailer.sendMail({
|
||||||
to: {
|
to: {
|
||||||
name: recipient.name,
|
name: recipient.name,
|
||||||
address: recipient.email,
|
address: recipient.email,
|
||||||
},
|
},
|
||||||
from: senderEmail,
|
from: senderEmail,
|
||||||
replyTo: replyToEmail,
|
replyTo: replyToEmail,
|
||||||
subject: renderCustomEmailTemplate(
|
subject: renderCustomEmailTemplate(
|
||||||
documentMeta?.subject || emailSubject,
|
documentMeta?.subject || emailSubject,
|
||||||
customEmailTemplate,
|
customEmailTemplate,
|
||||||
),
|
),
|
||||||
html,
|
html,
|
||||||
text,
|
text,
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
});
|
||||||
|
|
||||||
await io.runTask('update-recipient', async () => {
|
await io.runTask('update-recipient', async () => {
|
||||||
await prisma.recipient.update({
|
await prisma.recipient.update({
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"clean": "rimraf node_modules"
|
"clean": "rimraf node_modules"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ai-sdk/google-vertex": "3.0.81",
|
||||||
"@aws-sdk/client-s3": "^3.936.0",
|
"@aws-sdk/client-s3": "^3.936.0",
|
||||||
"@aws-sdk/client-sesv2": "^3.936.0",
|
"@aws-sdk/client-sesv2": "^3.936.0",
|
||||||
"@aws-sdk/cloudfront-signer": "^3.935.0",
|
"@aws-sdk/cloudfront-signer": "^3.935.0",
|
||||||
@@ -27,6 +28,7 @@
|
|||||||
"@lingui/core": "^5.6.0",
|
"@lingui/core": "^5.6.0",
|
||||||
"@lingui/macro": "^5.6.0",
|
"@lingui/macro": "^5.6.0",
|
||||||
"@lingui/react": "^5.6.0",
|
"@lingui/react": "^5.6.0",
|
||||||
|
"@napi-rs/canvas": "^0.1.83",
|
||||||
"@noble/ciphers": "0.6.0",
|
"@noble/ciphers": "0.6.0",
|
||||||
"@noble/hashes": "1.8.0",
|
"@noble/hashes": "1.8.0",
|
||||||
"@node-rs/bcrypt": "^1.10.7",
|
"@node-rs/bcrypt": "^1.10.7",
|
||||||
@@ -35,6 +37,7 @@
|
|||||||
"@sindresorhus/slugify": "^3.0.0",
|
"@sindresorhus/slugify": "^3.0.0",
|
||||||
"@team-plain/typescript-sdk": "^5.11.0",
|
"@team-plain/typescript-sdk": "^5.11.0",
|
||||||
"@vvo/tzdb": "^6.196.0",
|
"@vvo/tzdb": "^6.196.0",
|
||||||
|
"ai": "^5.0.104",
|
||||||
"csv-parse": "^6.1.0",
|
"csv-parse": "^6.1.0",
|
||||||
"inngest": "^3.45.1",
|
"inngest": "^3.45.1",
|
||||||
"jose": "^6.1.2",
|
"jose": "^6.1.2",
|
||||||
@@ -43,6 +46,7 @@
|
|||||||
"luxon": "^3.7.2",
|
"luxon": "^3.7.2",
|
||||||
"nanoid": "^5.1.6",
|
"nanoid": "^5.1.6",
|
||||||
"oslo": "^0.17.0",
|
"oslo": "^0.17.0",
|
||||||
|
"p-map": "^7.0.4",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"pino": "^9.14.0",
|
"pino": "^9.14.0",
|
||||||
"pino-pretty": "^13.1.2",
|
"pino-pretty": "^13.1.2",
|
||||||
@@ -63,4 +67,4 @@
|
|||||||
"@types/luxon": "^3.7.1",
|
"@types/luxon": "^3.7.1",
|
||||||
"@types/pg": "^8.15.6"
|
"@types/pg": "^8.15.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,6 @@ import { EnvelopeType } from '@prisma/client';
|
|||||||
|
|
||||||
import { mailer } from '@documenso/email/mailer';
|
import { mailer } from '@documenso/email/mailer';
|
||||||
import { AccessAuth2FAEmailTemplate } from '@documenso/email/templates/access-auth-2fa';
|
import { AccessAuth2FAEmailTemplate } from '@documenso/email/templates/access-auth-2fa';
|
||||||
import { isRecipientEmailValidForSending } from '@documenso/lib/utils/recipients';
|
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
|
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
|
||||||
@@ -70,12 +69,6 @@ export const send2FATokenEmail = async ({ token, envelopeId }: Send2FATokenEmail
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isRecipientEmailValidForSending(recipient)) {
|
|
||||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
|
||||||
message: 'Recipient is missing email address',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const twoFactorTokenToken = await generateTwoFactorTokenFromEmail({
|
const twoFactorTokenToken = await generateTwoFactorTokenFromEmail({
|
||||||
envelopeId,
|
envelopeId,
|
||||||
email: recipient.email,
|
email: recipient.email,
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
|||||||
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
|
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
|
||||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||||
import { isRecipientEmailValidForSending } from '../../utils/recipients';
|
|
||||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||||
import { getEmailContext } from '../email/get-email-context';
|
import { getEmailContext } from '../email/get-email-context';
|
||||||
|
|
||||||
@@ -65,18 +64,14 @@ export const adminSuperDeleteDocument = async ({
|
|||||||
envelope.documentMeta,
|
envelope.documentMeta,
|
||||||
).documentDeleted;
|
).documentDeleted;
|
||||||
|
|
||||||
const recipientsToNotify = envelope.recipients.filter((recipient) =>
|
|
||||||
isRecipientEmailValidForSending(recipient),
|
|
||||||
);
|
|
||||||
|
|
||||||
// if the document is pending, send cancellation emails to all recipients
|
// if the document is pending, send cancellation emails to all recipients
|
||||||
if (
|
if (
|
||||||
status === DocumentStatus.PENDING &&
|
status === DocumentStatus.PENDING &&
|
||||||
recipientsToNotify.length > 0 &&
|
envelope.recipients.length > 0 &&
|
||||||
isDocumentDeletedEmailEnabled
|
isDocumentDeletedEmailEnabled
|
||||||
) {
|
) {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
recipientsToNotify.map(async (recipient) => {
|
envelope.recipients.map(async (recipient) => {
|
||||||
if (recipient.sendStatus !== SendStatus.SENT) {
|
if (recipient.sendStatus !== SendStatus.SENT) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,47 @@
|
|||||||
|
import { createCanvas } from '@napi-rs/canvas';
|
||||||
|
import pMap from 'p-map';
|
||||||
|
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs';
|
||||||
|
|
||||||
|
export type PdfToImagesOptions = {
|
||||||
|
scale?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const pdfToImages = async (pdfBytes: Uint8Array, options: PdfToImagesOptions = {}) => {
|
||||||
|
const { scale = 2 } = options;
|
||||||
|
|
||||||
|
const pdf = await pdfjsLib.getDocument(pdfBytes).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 = createCanvas(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.encode('jpeg'),
|
||||||
|
width: Math.floor(viewport.width),
|
||||||
|
height: Math.floor(viewport.height),
|
||||||
|
mimeType: 'image/jpeg',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ concurrency: 10 },
|
||||||
|
);
|
||||||
|
|
||||||
|
void pdf.destroy();
|
||||||
|
|
||||||
|
return images;
|
||||||
|
};
|
||||||
@@ -2,7 +2,6 @@ import {
|
|||||||
DocumentSigningOrder,
|
DocumentSigningOrder,
|
||||||
DocumentStatus,
|
DocumentStatus,
|
||||||
EnvelopeType,
|
EnvelopeType,
|
||||||
FieldType,
|
|
||||||
RecipientRole,
|
RecipientRole,
|
||||||
SendStatus,
|
SendStatus,
|
||||||
SigningStatus,
|
SigningStatus,
|
||||||
@@ -44,14 +43,6 @@ export type CompleteDocumentWithTokenOptions = {
|
|||||||
email: string;
|
email: string;
|
||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
/**
|
|
||||||
* Override the recipient information. This will only work if the recipient
|
|
||||||
* does not have a name or email set.
|
|
||||||
*/
|
|
||||||
recipientOverride?: {
|
|
||||||
email?: string;
|
|
||||||
name?: string;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const completeDocumentWithToken = async ({
|
export const completeDocumentWithToken = async ({
|
||||||
@@ -61,7 +52,6 @@ export const completeDocumentWithToken = async ({
|
|||||||
accessAuthOptions,
|
accessAuthOptions,
|
||||||
requestMetadata,
|
requestMetadata,
|
||||||
nextSigner,
|
nextSigner,
|
||||||
recipientOverride,
|
|
||||||
}: CompleteDocumentWithTokenOptions) => {
|
}: CompleteDocumentWithTokenOptions) => {
|
||||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||||
where: {
|
where: {
|
||||||
@@ -126,35 +116,6 @@ export const completeDocumentWithToken = async ({
|
|||||||
throw new Error(`Recipient ${recipient.id} has unsigned fields`);
|
throw new Error(`Recipient ${recipient.id} has unsigned fields`);
|
||||||
}
|
}
|
||||||
|
|
||||||
let recipientName = recipient.name;
|
|
||||||
let recipientEmail = recipient.email;
|
|
||||||
|
|
||||||
// Only trim the name if it's been derived.
|
|
||||||
if (!recipientName) {
|
|
||||||
recipientName = (
|
|
||||||
recipientOverride?.name ||
|
|
||||||
fields.find((field) => field.type === FieldType.NAME)?.customText ||
|
|
||||||
''
|
|
||||||
).trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only trim the email if it's been derived.
|
|
||||||
if (!recipient.email) {
|
|
||||||
recipientEmail = (
|
|
||||||
recipientOverride?.email ||
|
|
||||||
fields.find((field) => field.type === FieldType.EMAIL)?.customText ||
|
|
||||||
''
|
|
||||||
)
|
|
||||||
.trim()
|
|
||||||
.toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!recipientEmail) {
|
|
||||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
|
||||||
message: 'Recipient email is required',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check ACCESS AUTH 2FA validation during document completion
|
// Check ACCESS AUTH 2FA validation during document completion
|
||||||
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
||||||
documentAuth: envelope.authOptions,
|
documentAuth: envelope.authOptions,
|
||||||
@@ -168,12 +129,6 @@ export const completeDocumentWithToken = async ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!recipient.email.trim()) {
|
|
||||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
|
||||||
message: `Recipient ${recipient.id} requires an email because they have auth requirements.`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const isValid = await isRecipientAuthorized({
|
const isValid = await isRecipientAuthorized({
|
||||||
type: 'ACCESS_2FA',
|
type: 'ACCESS_2FA',
|
||||||
documentAuthOptions: envelope.authOptions,
|
documentAuthOptions: envelope.authOptions,
|
||||||
@@ -221,43 +176,9 @@ export const completeDocumentWithToken = async ({
|
|||||||
data: {
|
data: {
|
||||||
signingStatus: SigningStatus.SIGNED,
|
signingStatus: SigningStatus.SIGNED,
|
||||||
signedAt: new Date(),
|
signedAt: new Date(),
|
||||||
name: recipientName,
|
|
||||||
email: recipientEmail,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (recipientEmail !== recipient.email || recipientName !== recipient.name) {
|
|
||||||
await tx.documentAuditLog.create({
|
|
||||||
data: createDocumentAuditLogData({
|
|
||||||
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
|
|
||||||
envelopeId: envelope.id,
|
|
||||||
user: {
|
|
||||||
name: recipientName,
|
|
||||||
email: recipientEmail,
|
|
||||||
},
|
|
||||||
requestMetadata,
|
|
||||||
data: {
|
|
||||||
recipientEmail: recipient.email,
|
|
||||||
recipientName: recipient.name,
|
|
||||||
recipientId: recipient.id,
|
|
||||||
recipientRole: recipient.role,
|
|
||||||
changes: [
|
|
||||||
{
|
|
||||||
type: RECIPIENT_DIFF_TYPE.NAME,
|
|
||||||
from: recipient.name,
|
|
||||||
to: recipientName,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: RECIPIENT_DIFF_TYPE.EMAIL,
|
|
||||||
from: recipient.email,
|
|
||||||
to: recipientEmail,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const authOptions = extractDocumentAuthMethods({
|
const authOptions = extractDocumentAuthMethods({
|
||||||
documentAuth: envelope.authOptions,
|
documentAuth: envelope.authOptions,
|
||||||
recipientAuth: recipient.authOptions,
|
recipientAuth: recipient.authOptions,
|
||||||
@@ -268,13 +189,13 @@ export const completeDocumentWithToken = async ({
|
|||||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
|
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
|
||||||
envelopeId: envelope.id,
|
envelopeId: envelope.id,
|
||||||
user: {
|
user: {
|
||||||
name: recipientName,
|
name: recipient.name,
|
||||||
email: recipientEmail,
|
email: recipient.email,
|
||||||
},
|
},
|
||||||
requestMetadata,
|
requestMetadata,
|
||||||
data: {
|
data: {
|
||||||
recipientEmail: recipientEmail,
|
recipientEmail: recipient.email,
|
||||||
recipientName: recipientName,
|
recipientName: recipient.name,
|
||||||
recipientId: recipient.id,
|
recipientId: recipient.id,
|
||||||
recipientRole: recipient.role,
|
recipientRole: recipient.role,
|
||||||
actionAuth: authOptions.derivedRecipientActionAuth,
|
actionAuth: authOptions.derivedRecipientActionAuth,
|
||||||
@@ -283,15 +204,13 @@ export const completeDocumentWithToken = async ({
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
if (recipientEmail) {
|
await jobs.triggerJob({
|
||||||
await jobs.triggerJob({
|
name: 'send.recipient.signed.email',
|
||||||
name: 'send.recipient.signed.email',
|
payload: {
|
||||||
payload: {
|
documentId: legacyDocumentId,
|
||||||
documentId: legacyDocumentId,
|
recipientId: recipient.id,
|
||||||
recipientId: recipient.id,
|
},
|
||||||
},
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const pendingRecipients = await prisma.recipient.findMany({
|
const pendingRecipients = await prisma.recipient.findMany({
|
||||||
select: {
|
select: {
|
||||||
@@ -328,8 +247,8 @@ export const completeDocumentWithToken = async ({
|
|||||||
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
|
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
|
||||||
envelopeId: envelope.id,
|
envelopeId: envelope.id,
|
||||||
user: {
|
user: {
|
||||||
name: recipientName,
|
name: recipient.name,
|
||||||
email: recipientEmail,
|
email: recipient.email,
|
||||||
},
|
},
|
||||||
requestMetadata,
|
requestMetadata,
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import type { ApiRequestMetadata } from '../../universal/extract-request-metadat
|
|||||||
import { isDocumentCompleted } from '../../utils/document';
|
import { isDocumentCompleted } from '../../utils/document';
|
||||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||||
import { type EnvelopeIdOptions, unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
|
import { type EnvelopeIdOptions, unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
|
||||||
import { isRecipientEmailValidForSending } from '../../utils/recipients';
|
|
||||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||||
import { getEmailContext } from '../email/get-email-context';
|
import { getEmailContext } from '../email/get-email-context';
|
||||||
import { getMemberRoles } from '../team/get-member-roles';
|
import { getMemberRoles } from '../team/get-member-roles';
|
||||||
@@ -210,7 +209,7 @@ const handleDocumentOwnerDelete = async ({
|
|||||||
// Send cancellation emails to recipients.
|
// Send cancellation emails to recipients.
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
envelope.recipients.map(async (recipient) => {
|
envelope.recipients.map(async (recipient) => {
|
||||||
if (recipient.sendStatus !== SendStatus.SENT || !isRecipientEmailValidForSending(recipient)) {
|
if (recipient.sendStatus !== SendStatus.SENT) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
|||||||
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
|
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
|
||||||
import { isDocumentCompleted } from '../../utils/document';
|
import { isDocumentCompleted } from '../../utils/document';
|
||||||
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
||||||
import { isRecipientEmailValidForSending } from '../../utils/recipients';
|
|
||||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||||
import { getEmailContext } from '../email/get-email-context';
|
import { getEmailContext } from '../email/get-email-context';
|
||||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||||
@@ -119,7 +118,7 @@ export const resendDocument = async ({
|
|||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
recipientsToRemind.map(async (recipient) => {
|
recipientsToRemind.map(async (recipient) => {
|
||||||
if (recipient.role === RecipientRole.CC || !isRecipientEmailValidForSending(recipient)) {
|
if (recipient.role === RecipientRole.CC) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import { getFileServerSide } from '../../universal/upload/get-file.server';
|
|||||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||||
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
||||||
import { unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
|
import { unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
|
||||||
import { isRecipientEmailValidForSending } from '../../utils/recipients';
|
|
||||||
import { renderCustomEmailTemplate } from '../../utils/render-custom-email-template';
|
import { renderCustomEmailTemplate } from '../../utils/render-custom-email-template';
|
||||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||||
import { formatDocumentsPath } from '../../utils/teams';
|
import { formatDocumentsPath } from '../../utils/teams';
|
||||||
@@ -177,12 +176,8 @@ export const sendCompletedEmail = async ({ id, requestMetadata }: SendDocumentOp
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const recipientsToNotify = envelope.recipients.filter((recipient) =>
|
|
||||||
isRecipientEmailValidForSending(recipient),
|
|
||||||
);
|
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
recipientsToNotify.map(async (recipient) => {
|
envelope.recipients.map(async (recipient) => {
|
||||||
const customEmailTemplate = {
|
const customEmailTemplate = {
|
||||||
'signer.name': recipient.name,
|
'signer.name': recipient.name,
|
||||||
'signer.email': recipient.email,
|
'signer.email': recipient.email,
|
||||||
|
|||||||
@@ -35,10 +35,8 @@ import {
|
|||||||
import { getFileServerSide } from '../../universal/upload/get-file.server';
|
import { getFileServerSide } from '../../universal/upload/get-file.server';
|
||||||
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
|
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
|
||||||
import { isDocumentCompleted } from '../../utils/document';
|
import { isDocumentCompleted } from '../../utils/document';
|
||||||
import { extractDocumentAuthMethods } from '../../utils/document-auth';
|
|
||||||
import { type EnvelopeIdOptions, mapSecondaryIdToDocumentId } from '../../utils/envelope';
|
import { type EnvelopeIdOptions, mapSecondaryIdToDocumentId } from '../../utils/envelope';
|
||||||
import { toCheckboxCustomText, toRadioCustomText } from '../../utils/fields';
|
import { toCheckboxCustomText, toRadioCustomText } from '../../utils/fields';
|
||||||
import { isRecipientEmailValidForSending } from '../../utils/recipients';
|
|
||||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||||
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
|
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
|
||||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||||
@@ -130,24 +128,6 @@ export const sendDocument = async ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate that recipients with auth requirements have a valid email.
|
|
||||||
envelope.recipients.forEach((recipient) => {
|
|
||||||
const auth = extractDocumentAuthMethods({
|
|
||||||
documentAuth: envelope.authOptions,
|
|
||||||
recipientAuth: recipient.authOptions,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (
|
|
||||||
recipient.role !== RecipientRole.CC &&
|
|
||||||
(auth.recipientAccessAuthRequired || auth.recipientActionAuthRequired) &&
|
|
||||||
!isRecipientEmailValidForSending(recipient)
|
|
||||||
) {
|
|
||||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
|
||||||
message: `Recipient ${recipient.id} requires an email because they have auth requirements.`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Commented out server side checks for minimum 1 signature per signer now since we need to
|
// Commented out server side checks for minimum 1 signature per signer now since we need to
|
||||||
// decide if we want to enforce this for API & templates.
|
// decide if we want to enforce this for API & templates.
|
||||||
// const fields = await getFieldsForDocument({
|
// const fields = await getFieldsForDocument({
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
|||||||
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
|
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
|
||||||
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
||||||
import { unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
|
import { unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
|
||||||
import { isRecipientEmailValidForSending } from '../../utils/recipients';
|
|
||||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||||
import { getEmailContext } from '../email/get-email-context';
|
import { getEmailContext } from '../email/get-email-context';
|
||||||
|
|
||||||
@@ -70,11 +69,6 @@ export const sendPendingEmail = async ({ id, recipientId }: SendPendingEmailOpti
|
|||||||
|
|
||||||
const { email, name } = recipient;
|
const { email, name } = recipient;
|
||||||
|
|
||||||
// Skip sending email if recipient has no email address
|
|
||||||
if (!isRecipientEmailValidForSending(recipient)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||||
|
|
||||||
const template = createElement(DocumentPendingEmailTemplate, {
|
const template = createElement(DocumentPendingEmailTemplate, {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export interface CreateEnvelopeRecipientsOptions {
|
|||||||
accessAuth?: TRecipientAccessAuthTypes[];
|
accessAuth?: TRecipientAccessAuthTypes[];
|
||||||
actionAuth?: TRecipientActionAuthTypes[];
|
actionAuth?: TRecipientActionAuthTypes[];
|
||||||
}[];
|
}[];
|
||||||
requestMetadata: ApiRequestMetadata;
|
requestMetadata?: ApiRequestMetadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createEnvelopeRecipients = async ({
|
export const createEnvelopeRecipients = async ({
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
|||||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||||
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
|
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
|
||||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||||
import { canRecipientBeModified, isRecipientEmailValidForSending } from '../../utils/recipients';
|
import { canRecipientBeModified } from '../../utils/recipients';
|
||||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||||
import { getEmailContext } from '../email/get-email-context';
|
import { getEmailContext } from '../email/get-email-context';
|
||||||
@@ -142,8 +142,7 @@ export const deleteEnvelopeRecipient = async ({
|
|||||||
if (
|
if (
|
||||||
recipientToDelete.sendStatus === SendStatus.SENT &&
|
recipientToDelete.sendStatus === SendStatus.SENT &&
|
||||||
isRecipientRemovedEmailEnabled &&
|
isRecipientRemovedEmailEnabled &&
|
||||||
envelope.type === EnvelopeType.DOCUMENT &&
|
envelope.type === EnvelopeType.DOCUMENT
|
||||||
isRecipientEmailValidForSending(recipientToDelete)
|
|
||||||
) {
|
) {
|
||||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
|||||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||||
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
|
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
|
||||||
import { type EnvelopeIdOptions, mapSecondaryIdToDocumentId } from '../../utils/envelope';
|
import { type EnvelopeIdOptions, mapSecondaryIdToDocumentId } from '../../utils/envelope';
|
||||||
import { canRecipientBeModified, isRecipientEmailValidForSending } from '../../utils/recipients';
|
import { canRecipientBeModified } from '../../utils/recipients';
|
||||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||||
import { getEmailContext } from '../email/get-email-context';
|
import { getEmailContext } from '../email/get-email-context';
|
||||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||||
@@ -294,14 +294,10 @@ export const setDocumentRecipients = async ({
|
|||||||
envelope.documentMeta,
|
envelope.documentMeta,
|
||||||
).recipientRemoved;
|
).recipientRemoved;
|
||||||
|
|
||||||
// Send emails to deleted recipients who have emails.
|
// Send emails to deleted recipients.
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
removedRecipients.map(async (recipient) => {
|
removedRecipients.map(async (recipient) => {
|
||||||
if (
|
if (recipient.sendStatus !== SendStatus.SENT || !isRecipientRemovedEmailEnabled) {
|
||||||
recipient.sendStatus !== SendStatus.SENT ||
|
|
||||||
!isRecipientRemovedEmailEnabled ||
|
|
||||||
!isRecipientEmailValidForSending(recipient)
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2817,6 +2817,10 @@ msgstr "Erstellt am {0}"
|
|||||||
msgid "CSV Structure"
|
msgid "CSV Structure"
|
||||||
msgstr "CSV-Struktur"
|
msgstr "CSV-Struktur"
|
||||||
|
|
||||||
|
#: apps/remix/app/routes/_authenticated+/admin+/stats.tsx
|
||||||
|
msgid "Cumulative MAU (signed in)"
|
||||||
|
msgstr "Kumulative MAU (angemeldet)"
|
||||||
|
|
||||||
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
|
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
|
||||||
msgid "Current"
|
msgid "Current"
|
||||||
msgstr "Aktuell"
|
msgstr "Aktuell"
|
||||||
|
|||||||
@@ -2817,6 +2817,10 @@ msgstr "Creado el {0}"
|
|||||||
msgid "CSV Structure"
|
msgid "CSV Structure"
|
||||||
msgstr "Estructura CSV"
|
msgstr "Estructura CSV"
|
||||||
|
|
||||||
|
#: apps/remix/app/routes/_authenticated+/admin+/stats.tsx
|
||||||
|
msgid "Cumulative MAU (signed in)"
|
||||||
|
msgstr "MAU acumulativo (con sesión iniciada)"
|
||||||
|
|
||||||
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
|
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
|
||||||
msgid "Current"
|
msgid "Current"
|
||||||
msgstr "Actual"
|
msgstr "Actual"
|
||||||
|
|||||||
@@ -2817,6 +2817,10 @@ msgstr "Créé le {0}"
|
|||||||
msgid "CSV Structure"
|
msgid "CSV Structure"
|
||||||
msgstr "Structure CSV"
|
msgstr "Structure CSV"
|
||||||
|
|
||||||
|
#: apps/remix/app/routes/_authenticated+/admin+/stats.tsx
|
||||||
|
msgid "Cumulative MAU (signed in)"
|
||||||
|
msgstr "MAU cumulatif (connecté)"
|
||||||
|
|
||||||
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
|
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
|
||||||
msgid "Current"
|
msgid "Current"
|
||||||
msgstr "Actuel"
|
msgstr "Actuel"
|
||||||
|
|||||||
@@ -2817,6 +2817,10 @@ msgstr "Creato il {0}"
|
|||||||
msgid "CSV Structure"
|
msgid "CSV Structure"
|
||||||
msgstr "Struttura CSV"
|
msgstr "Struttura CSV"
|
||||||
|
|
||||||
|
#: apps/remix/app/routes/_authenticated+/admin+/stats.tsx
|
||||||
|
msgid "Cumulative MAU (signed in)"
|
||||||
|
msgstr "MAU cumulativi (autenticati)"
|
||||||
|
|
||||||
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
|
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
|
||||||
msgid "Current"
|
msgid "Current"
|
||||||
msgstr "Corrente"
|
msgstr "Corrente"
|
||||||
|
|||||||
@@ -2817,6 +2817,10 @@ msgstr "作成日 {0}"
|
|||||||
msgid "CSV Structure"
|
msgid "CSV Structure"
|
||||||
msgstr "CSV 構造"
|
msgstr "CSV 構造"
|
||||||
|
|
||||||
|
#: apps/remix/app/routes/_authenticated+/admin+/stats.tsx
|
||||||
|
msgid "Cumulative MAU (signed in)"
|
||||||
|
msgstr "累積 MAU(サインイン済み)"
|
||||||
|
|
||||||
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
|
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
|
||||||
msgid "Current"
|
msgid "Current"
|
||||||
msgstr "現在"
|
msgstr "現在"
|
||||||
|
|||||||
@@ -2817,6 +2817,10 @@ msgstr "{0}에 생성됨"
|
|||||||
msgid "CSV Structure"
|
msgid "CSV Structure"
|
||||||
msgstr "CSV 구조"
|
msgstr "CSV 구조"
|
||||||
|
|
||||||
|
#: apps/remix/app/routes/_authenticated+/admin+/stats.tsx
|
||||||
|
msgid "Cumulative MAU (signed in)"
|
||||||
|
msgstr "누적 MAU(로그인 기준)"
|
||||||
|
|
||||||
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
|
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
|
||||||
msgid "Current"
|
msgid "Current"
|
||||||
msgstr "현재"
|
msgstr "현재"
|
||||||
|
|||||||
@@ -2817,6 +2817,10 @@ msgstr "Aangemaakt op {0}"
|
|||||||
msgid "CSV Structure"
|
msgid "CSV Structure"
|
||||||
msgstr "CSV-structuur"
|
msgstr "CSV-structuur"
|
||||||
|
|
||||||
|
#: apps/remix/app/routes/_authenticated+/admin+/stats.tsx
|
||||||
|
msgid "Cumulative MAU (signed in)"
|
||||||
|
msgstr "Cumulatieve MAU (ingelogd)"
|
||||||
|
|
||||||
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
|
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
|
||||||
msgid "Current"
|
msgid "Current"
|
||||||
msgstr "Huidig"
|
msgstr "Huidig"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ msgstr ""
|
|||||||
"Language: pl\n"
|
"Language: pl\n"
|
||||||
"Project-Id-Version: documenso-app\n"
|
"Project-Id-Version: documenso-app\n"
|
||||||
"Report-Msgid-Bugs-To: \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"
|
"Last-Translator: \n"
|
||||||
"Language-Team: Polish\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"
|
"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+/o.$orgUrl.settings.members.tsx
|
||||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
|
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
|
||||||
msgid "Active"
|
msgid "Active"
|
||||||
msgstr "Aktywny"
|
msgstr "Aktywne"
|
||||||
|
|
||||||
#: apps/remix/app/routes/_authenticated+/settings+/security._index.tsx
|
#: apps/remix/app/routes/_authenticated+/settings+/security._index.tsx
|
||||||
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.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
|
#: 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."
|
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
|
#: apps/remix/app/components/dialogs/document-delete-dialog.tsx
|
||||||
msgid "All recipients will be notified"
|
msgid "All recipients will be notified"
|
||||||
@@ -2817,6 +2817,10 @@ msgstr "Utworzono {0}"
|
|||||||
msgid "CSV Structure"
|
msgid "CSV Structure"
|
||||||
msgstr "Struktura CSV"
|
msgstr "Struktura CSV"
|
||||||
|
|
||||||
|
#: apps/remix/app/routes/_authenticated+/admin+/stats.tsx
|
||||||
|
msgid "Cumulative MAU (signed in)"
|
||||||
|
msgstr "Łączna liczba MAU (zalogowani)"
|
||||||
|
|
||||||
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
|
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
|
||||||
msgid "Current"
|
msgid "Current"
|
||||||
msgstr "Obecna"
|
msgstr "Obecna"
|
||||||
@@ -3098,7 +3102,7 @@ msgstr "Szczegóły"
|
|||||||
|
|
||||||
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
|
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
|
||||||
msgid "Developer Mode"
|
msgid "Developer Mode"
|
||||||
msgstr "Tryb deweloperski"
|
msgstr "Tryb programisty"
|
||||||
|
|
||||||
#: apps/remix/app/components/tables/settings-security-activity-table.tsx
|
#: apps/remix/app/components/tables/settings-security-activity-table.tsx
|
||||||
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
|
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
|
||||||
@@ -3712,7 +3716,7 @@ msgstr "Przeciągnij i upuść plik PDF"
|
|||||||
#: packages/ui/primitives/signature-pad/signature-pad.tsx
|
#: packages/ui/primitives/signature-pad/signature-pad.tsx
|
||||||
msgctxt "Draw signature"
|
msgctxt "Draw signature"
|
||||||
msgid "Draw"
|
msgid "Draw"
|
||||||
msgstr "Rysuj"
|
msgstr "Rysowany"
|
||||||
|
|
||||||
#: apps/remix/app/components/embed/authoring/configure-document-upload.tsx
|
#: apps/remix/app/components/embed/authoring/configure-document-upload.tsx
|
||||||
msgid "Drop your document here"
|
msgid "Drop your document here"
|
||||||
@@ -4252,7 +4256,7 @@ msgstr "Wszyscy podpisali"
|
|||||||
|
|
||||||
#: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx
|
#: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx
|
||||||
msgid "Everyone has signed! You will receive an email copy of the signed document."
|
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
|
#: apps/remix/app/components/dialogs/passkey-create-dialog.tsx
|
||||||
msgid "Exceeded timeout"
|
msgid "Exceeded timeout"
|
||||||
@@ -4833,7 +4837,7 @@ msgstr "Uwaga: Co to oznacza"
|
|||||||
|
|
||||||
#: apps/remix/app/components/tables/user-billing-organisations-table.tsx
|
#: apps/remix/app/components/tables/user-billing-organisations-table.tsx
|
||||||
msgid "Inactive"
|
msgid "Inactive"
|
||||||
msgstr "Nieaktywne"
|
msgstr "Nieaktywna"
|
||||||
|
|
||||||
#: apps/remix/app/components/general/app-nav-mobile.tsx
|
#: apps/remix/app/components/general/app-nav-mobile.tsx
|
||||||
#: apps/remix/app/components/general/app-nav-mobile.tsx
|
#: apps/remix/app/components/general/app-nav-mobile.tsx
|
||||||
@@ -5204,7 +5208,7 @@ msgstr "Wygenerowane linki"
|
|||||||
|
|
||||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
|
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
|
||||||
msgid "Listening to"
|
msgid "Listening to"
|
||||||
msgstr "Nasłuchuje"
|
msgstr "Nasłuchiwanie"
|
||||||
|
|
||||||
#: apps/remix/app/components/general/document/document-page-view-recent-activity.tsx
|
#: apps/remix/app/components/general/document/document-page-view-recent-activity.tsx
|
||||||
msgid "Load older activity"
|
msgid "Load older activity"
|
||||||
@@ -5248,7 +5252,7 @@ msgstr "Zaloguj się"
|
|||||||
|
|
||||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
|
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
|
||||||
msgid "Logs"
|
msgid "Logs"
|
||||||
msgstr "Dzienniki"
|
msgstr "Logi"
|
||||||
|
|
||||||
#: apps/remix/app/components/tables/admin-organisations-table.tsx
|
#: apps/remix/app/components/tables/admin-organisations-table.tsx
|
||||||
#: apps/remix/app/components/tables/organisation-teams-table.tsx
|
#: apps/remix/app/components/tables/organisation-teams-table.tsx
|
||||||
@@ -5288,7 +5292,7 @@ msgstr "Zarządzaj płatnościami"
|
|||||||
|
|
||||||
#: apps/remix/app/routes/_authenticated+/settings+/billing.tsx
|
#: apps/remix/app/routes/_authenticated+/settings+/billing.tsx
|
||||||
msgid "Manage billing and subscriptions for organisations where you have billing management permissions."
|
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
|
#: apps/remix/app/components/dialogs/public-profile-template-manage-dialog.tsx
|
||||||
msgid "Manage details for this public template"
|
msgid "Manage details for this public template"
|
||||||
@@ -6188,7 +6192,7 @@ msgstr "Hasło zostało zaktualizowane!"
|
|||||||
|
|
||||||
#: apps/remix/app/components/tables/user-billing-organisations-table.tsx
|
#: apps/remix/app/components/tables/user-billing-organisations-table.tsx
|
||||||
msgid "Past Due"
|
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
|
||||||
#: apps/remix/app/components/general/organisations/organisation-billing-banner.tsx
|
#: apps/remix/app/components/general/organisations/organisation-billing-banner.tsx
|
||||||
@@ -6408,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
|
#: apps/remix/app/components/general/document-signing/document-signing-reject-dialog.tsx
|
||||||
msgid "Please provide a reason for rejecting this document"
|
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
|
#: 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."
|
msgid "Please provide a token from the authenticator, or a backup code. If you do not have a backup code available, please contact support."
|
||||||
@@ -7130,7 +7134,7 @@ msgstr "Szukaj tytułu dokumentu"
|
|||||||
|
|
||||||
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks.$id._index.tsx
|
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks.$id._index.tsx
|
||||||
msgid "Search by ID"
|
msgid "Search by ID"
|
||||||
msgstr "Szukaj po ID"
|
msgstr "Szukaj identyfikatora"
|
||||||
|
|
||||||
#: apps/remix/app/components/tables/admin-dashboard-users-table.tsx
|
#: apps/remix/app/components/tables/admin-dashboard-users-table.tsx
|
||||||
msgid "Search by name or email"
|
msgid "Search by name or email"
|
||||||
@@ -7227,7 +7231,7 @@ msgstr "Wybierz metody dostępu"
|
|||||||
|
|
||||||
#: apps/remix/app/components/dialogs/webhook-test-dialog.tsx
|
#: apps/remix/app/components/dialogs/webhook-test-dialog.tsx
|
||||||
msgid "Select an event type"
|
msgid "Select an event type"
|
||||||
msgstr "Wybierz typ zdarzenia"
|
msgstr "Wybierz rodzaj zdarzenia"
|
||||||
|
|
||||||
#: apps/remix/app/components/dialogs/sign-field-dropdown-dialog.tsx
|
#: apps/remix/app/components/dialogs/sign-field-dropdown-dialog.tsx
|
||||||
#: packages/ui/primitives/combobox.tsx
|
#: packages/ui/primitives/combobox.tsx
|
||||||
@@ -8376,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
|
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks.$id._index.tsx
|
||||||
msgid "Test"
|
msgid "Test"
|
||||||
msgstr "Test"
|
msgstr "Testuj"
|
||||||
|
|
||||||
#: apps/remix/app/components/dialogs/webhook-test-dialog.tsx
|
#: apps/remix/app/components/dialogs/webhook-test-dialog.tsx
|
||||||
msgid "Test Webhook"
|
msgid "Test Webhook"
|
||||||
@@ -9253,13 +9257,13 @@ msgstr "Weryfikacja dwuetapowa"
|
|||||||
#: apps/remix/app/components/tables/admin-document-recipient-item-table.tsx
|
#: apps/remix/app/components/tables/admin-document-recipient-item-table.tsx
|
||||||
#: apps/remix/app/components/tables/templates-table.tsx
|
#: apps/remix/app/components/tables/templates-table.tsx
|
||||||
msgid "Type"
|
msgid "Type"
|
||||||
msgstr "Typ"
|
msgstr "Rodzaj"
|
||||||
|
|
||||||
#: packages/lib/constants/document.ts
|
#: packages/lib/constants/document.ts
|
||||||
#: packages/ui/primitives/signature-pad/signature-pad.tsx
|
#: packages/ui/primitives/signature-pad/signature-pad.tsx
|
||||||
msgctxt "Type signature"
|
msgctxt "Type signature"
|
||||||
msgid "Type"
|
msgid "Type"
|
||||||
msgstr "Wpisz"
|
msgstr "Pisany"
|
||||||
|
|
||||||
#: apps/remix/app/components/general/app-command-menu.tsx
|
#: apps/remix/app/components/general/app-command-menu.tsx
|
||||||
msgid "Type a command or search..."
|
msgid "Type a command or search..."
|
||||||
@@ -9556,7 +9560,7 @@ msgstr "Przesłany"
|
|||||||
#: packages/ui/primitives/signature-pad/signature-pad.tsx
|
#: packages/ui/primitives/signature-pad/signature-pad.tsx
|
||||||
msgctxt "Upload signature"
|
msgctxt "Upload signature"
|
||||||
msgid "Upload"
|
msgid "Upload"
|
||||||
msgstr "Prześlij"
|
msgstr "Przesłany"
|
||||||
|
|
||||||
#: apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx
|
#: 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."
|
msgid "Upload a CSV file to create multiple documents from this template. Each row represents one document with its recipient details."
|
||||||
@@ -10346,7 +10350,7 @@ msgstr "Webhook nie został znaleziony"
|
|||||||
|
|
||||||
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
|
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
|
||||||
msgid "Webhook successfully sent"
|
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
|
#: apps/remix/app/components/dialogs/webhook-edit-dialog.tsx
|
||||||
msgid "Webhook updated"
|
msgid "Webhook updated"
|
||||||
@@ -10708,7 +10712,7 @@ msgstr "Nie masz uprawnień do utworzenia tokena dla tego zespołu"
|
|||||||
|
|
||||||
#: apps/remix/app/components/tables/user-billing-organisations-table.tsx
|
#: apps/remix/app/components/tables/user-billing-organisations-table.tsx
|
||||||
msgid "You don't manage billing for any organisations."
|
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
|
#: packages/email/template-components/template-document-cancel.tsx
|
||||||
msgid "You don't need to sign it anymore."
|
msgid "You don't need to sign it anymore."
|
||||||
@@ -10927,7 +10931,7 @@ msgstr "Kod z aplikacji uwierzytelniającej będzie wymagany podczas logowania."
|
|||||||
|
|
||||||
#: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx
|
#: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx
|
||||||
msgid "You will receive an email copy of the signed document once everyone has signed."
|
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
|
#: apps/remix/app/routes/_unauthenticated+/organisation.sso.confirmation.$token.tsx
|
||||||
msgid "Your Account"
|
msgid "Your Account"
|
||||||
|
|||||||
@@ -2817,6 +2817,10 @@ msgstr "创建于 {0}"
|
|||||||
msgid "CSV Structure"
|
msgid "CSV Structure"
|
||||||
msgstr "CSV 结构"
|
msgstr "CSV 结构"
|
||||||
|
|
||||||
|
#: apps/remix/app/routes/_authenticated+/admin+/stats.tsx
|
||||||
|
msgid "Cumulative MAU (signed in)"
|
||||||
|
msgstr "累计月活跃用户(已登录)"
|
||||||
|
|
||||||
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
|
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
|
||||||
msgid "Current"
|
msgid "Current"
|
||||||
msgstr "当前"
|
msgstr "当前"
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { RecipientSchema } from '@documenso/prisma/generated/zod/modelSchema/RecipientSchema';
|
import { RecipientSchema } from '@documenso/prisma/generated/zod/modelSchema/RecipientSchema';
|
||||||
@@ -111,13 +110,3 @@ export const ZEnvelopeRecipientManySchema = ZRecipientManySchema.omit({
|
|||||||
documentId: true,
|
documentId: true,
|
||||||
templateId: true,
|
templateId: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ZRecipientEmailSchema = z.union([
|
|
||||||
z.literal(''),
|
|
||||||
z
|
|
||||||
.string()
|
|
||||||
.trim()
|
|
||||||
.toLowerCase()
|
|
||||||
.email({ message: msg`Invalid email`.id })
|
|
||||||
.max(254),
|
|
||||||
]);
|
|
||||||
|
|||||||
@@ -135,5 +135,7 @@ export const generateDefaultOrganisationSettings = (): Omit<
|
|||||||
emailReplyTo: null,
|
emailReplyTo: null,
|
||||||
// emailReplyToName: null,
|
// emailReplyToName: null,
|
||||||
emailDocumentSettings: DEFAULT_DOCUMENT_EMAIL_SETTINGS,
|
emailDocumentSettings: DEFAULT_DOCUMENT_EMAIL_SETTINGS,
|
||||||
|
|
||||||
|
aiFeaturesEnabled: false,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { Envelope } from '@prisma/client';
|
import type { Envelope } from '@prisma/client';
|
||||||
import { type Field, type Recipient, RecipientRole, SigningStatus } from '@prisma/client';
|
import { type Field, type Recipient, RecipientRole, SigningStatus } from '@prisma/client';
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
|
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
|
||||||
import { extractLegacyIds } from '../universal/id';
|
import { extractLegacyIds } from '../universal/id';
|
||||||
@@ -59,7 +58,3 @@ export const mapRecipientToLegacyRecipient = (
|
|||||||
...legacyId,
|
...legacyId,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isRecipientEmailValidForSending = (recipient: Pick<Recipient, 'email'>) => {
|
|
||||||
return z.string().email().safeParse(recipient.email).success;
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -202,6 +202,8 @@ export const generateDefaultTeamSettings = (): Omit<TeamGlobalSettings, 'id' | '
|
|||||||
emailId: null,
|
emailId: null,
|
||||||
emailReplyTo: null,
|
emailReplyTo: null,
|
||||||
// emailReplyToName: null,
|
// emailReplyToName: null,
|
||||||
|
|
||||||
|
aiFeaturesEnabled: null,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "OrganisationGlobalSettings" ADD COLUMN "aiFeaturesEnabled" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "TeamGlobalSettings" ADD COLUMN "aiFeaturesEnabled" BOOLEAN;
|
||||||
|
|
||||||
@@ -834,6 +834,9 @@ model OrganisationGlobalSettings {
|
|||||||
brandingLogo String @default("")
|
brandingLogo String @default("")
|
||||||
brandingUrl String @default("")
|
brandingUrl String @default("")
|
||||||
brandingCompanyDetails String @default("")
|
brandingCompanyDetails String @default("")
|
||||||
|
|
||||||
|
// AI features settings.
|
||||||
|
aiFeaturesEnabled Boolean @default(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// @zod.import(["import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';"])
|
/// @zod.import(["import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';"])
|
||||||
@@ -865,6 +868,9 @@ model TeamGlobalSettings {
|
|||||||
brandingLogo String?
|
brandingLogo String?
|
||||||
brandingUrl String?
|
brandingUrl String?
|
||||||
brandingCompanyDetails String?
|
brandingCompanyDetails String?
|
||||||
|
|
||||||
|
// AI features settings.
|
||||||
|
aiFeaturesEnabled Boolean?
|
||||||
}
|
}
|
||||||
|
|
||||||
model Team {
|
model Team {
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import {
|
|||||||
ZFieldWidthSchema,
|
ZFieldWidthSchema,
|
||||||
} from '@documenso/lib/types/field';
|
} from '@documenso/lib/types/field';
|
||||||
import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
|
import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
|
||||||
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
|
|
||||||
|
|
||||||
import { ZDocumentTitleSchema } from '../document-router/schema';
|
import { ZDocumentTitleSchema } from '../document-router/schema';
|
||||||
|
|
||||||
@@ -31,7 +30,7 @@ export const ZCreateEmbeddingTemplateRequestSchema = z.object({
|
|||||||
documentDataId: z.string(),
|
documentDataId: z.string(),
|
||||||
recipients: z.array(
|
recipients: z.array(
|
||||||
z.object({
|
z.object({
|
||||||
email: ZRecipientEmailSchema,
|
email: z.union([z.string().length(0), z.string().email()]),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
role: z.nativeEnum(RecipientRole),
|
role: z.nativeEnum(RecipientRole),
|
||||||
signingOrder: z.number().optional(),
|
signingOrder: z.number().optional(),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
|
import { DocumentSigningOrder, FieldType, RecipientRole } from '@prisma/client';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
|
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
|
||||||
@@ -21,11 +21,22 @@ import {
|
|||||||
ZFieldPageYSchema,
|
ZFieldPageYSchema,
|
||||||
ZFieldWidthSchema,
|
ZFieldWidthSchema,
|
||||||
} from '@documenso/lib/types/field';
|
} from '@documenso/lib/types/field';
|
||||||
import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
|
import { ZFieldAndMetaSchema, ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
||||||
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
|
|
||||||
|
|
||||||
import { ZDocumentTitleSchema } from '../document-router/schema';
|
import { ZDocumentTitleSchema } from '../document-router/schema';
|
||||||
|
|
||||||
|
const ZFieldSchema = z.object({
|
||||||
|
id: z.number().optional(),
|
||||||
|
type: z.nativeEnum(FieldType),
|
||||||
|
pageNumber: ZFieldPageNumberSchema,
|
||||||
|
pageX: ZFieldPageXSchema,
|
||||||
|
pageY: ZFieldPageYSchema,
|
||||||
|
width: ZFieldWidthSchema,
|
||||||
|
height: ZFieldHeightSchema,
|
||||||
|
fieldMeta: ZFieldMetaSchema.optional(),
|
||||||
|
envelopeItemId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
export const ZUpdateEmbeddingTemplateRequestSchema = z.object({
|
export const ZUpdateEmbeddingTemplateRequestSchema = z.object({
|
||||||
templateId: z.number(),
|
templateId: z.number(),
|
||||||
title: ZDocumentTitleSchema.optional(),
|
title: ZDocumentTitleSchema.optional(),
|
||||||
@@ -33,7 +44,7 @@ export const ZUpdateEmbeddingTemplateRequestSchema = z.object({
|
|||||||
recipients: z.array(
|
recipients: z.array(
|
||||||
z.object({
|
z.object({
|
||||||
id: z.number().optional(),
|
id: z.number().optional(),
|
||||||
email: ZRecipientEmailSchema,
|
email: z.union([z.string().length(0), z.string().email()]),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
role: z.nativeEnum(RecipientRole),
|
role: z.nativeEnum(RecipientRole),
|
||||||
signingOrder: z.number().optional(),
|
signingOrder: z.number().optional(),
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ import {
|
|||||||
ZDocumentTitleSchema,
|
ZDocumentTitleSchema,
|
||||||
ZDocumentVisibilitySchema,
|
ZDocumentVisibilitySchema,
|
||||||
} from '../document-router/schema';
|
} from '../document-router/schema';
|
||||||
|
import { ZCreateRecipientSchema } from '../recipient-router/schema';
|
||||||
import type { TrpcRouteMeta } from '../trpc';
|
import type { TrpcRouteMeta } from '../trpc';
|
||||||
import { ZCreateEnvelopeRecipientSchema } from './envelope-recipients/create-envelope-recipients.types';
|
|
||||||
|
|
||||||
export const createEnvelopeMeta: TrpcRouteMeta = {
|
export const createEnvelopeMeta: TrpcRouteMeta = {
|
||||||
openapi: {
|
openapi: {
|
||||||
@@ -54,7 +54,7 @@ export const ZCreateEnvelopePayloadSchema = z.object({
|
|||||||
.optional(),
|
.optional(),
|
||||||
recipients: z
|
recipients: z
|
||||||
.array(
|
.array(
|
||||||
ZCreateEnvelopeRecipientSchema.extend({
|
ZCreateRecipientSchema.extend({
|
||||||
fields: ZEnvelopeFieldAndMetaSchema.and(
|
fields: ZEnvelopeFieldAndMetaSchema.and(
|
||||||
z.object({
|
z.object({
|
||||||
identifier: z
|
identifier: z
|
||||||
|
|||||||
+3
-19
@@ -1,15 +1,8 @@
|
|||||||
import { RecipientRole } from '@prisma/client';
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import {
|
import { ZEnvelopeRecipientLiteSchema } from '@documenso/lib/types/recipient';
|
||||||
ZRecipientAccessAuthTypesSchema,
|
|
||||||
ZRecipientActionAuthTypesSchema,
|
|
||||||
} from '@documenso/lib/types/document-auth';
|
|
||||||
import {
|
|
||||||
ZEnvelopeRecipientLiteSchema,
|
|
||||||
ZRecipientEmailSchema,
|
|
||||||
} from '@documenso/lib/types/recipient';
|
|
||||||
|
|
||||||
|
import { ZCreateRecipientSchema } from '../../recipient-router/schema';
|
||||||
import type { TrpcRouteMeta } from '../../trpc';
|
import type { TrpcRouteMeta } from '../../trpc';
|
||||||
|
|
||||||
export const createEnvelopeRecipientsMeta: TrpcRouteMeta = {
|
export const createEnvelopeRecipientsMeta: TrpcRouteMeta = {
|
||||||
@@ -22,18 +15,9 @@ export const createEnvelopeRecipientsMeta: TrpcRouteMeta = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ZCreateEnvelopeRecipientSchema = z.object({
|
|
||||||
email: ZRecipientEmailSchema,
|
|
||||||
name: z.string().max(255),
|
|
||||||
role: z.nativeEnum(RecipientRole),
|
|
||||||
signingOrder: z.number().optional(),
|
|
||||||
accessAuth: z.array(ZRecipientAccessAuthTypesSchema).default([]).optional(),
|
|
||||||
actionAuth: z.array(ZRecipientActionAuthTypesSchema).default([]).optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const ZCreateEnvelopeRecipientsRequestSchema = z.object({
|
export const ZCreateEnvelopeRecipientsRequestSchema = z.object({
|
||||||
envelopeId: z.string(),
|
envelopeId: z.string(),
|
||||||
data: ZCreateEnvelopeRecipientSchema.array(),
|
data: ZCreateRecipientSchema.array(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ZCreateEnvelopeRecipientsResponseSchema = z.object({
|
export const ZCreateEnvelopeRecipientsResponseSchema = z.object({
|
||||||
|
|||||||
+3
-17
@@ -1,12 +1,8 @@
|
|||||||
import { RecipientRole } from '@prisma/client';
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import {
|
import { ZRecipientLiteSchema } from '@documenso/lib/types/recipient';
|
||||||
ZRecipientAccessAuthTypesSchema,
|
|
||||||
ZRecipientActionAuthTypesSchema,
|
|
||||||
} from '@documenso/lib/types/document-auth';
|
|
||||||
import { ZRecipientEmailSchema, ZRecipientLiteSchema } from '@documenso/lib/types/recipient';
|
|
||||||
|
|
||||||
|
import { ZUpdateRecipientSchema } from '../../recipient-router/schema';
|
||||||
import type { TrpcRouteMeta } from '../../trpc';
|
import type { TrpcRouteMeta } from '../../trpc';
|
||||||
|
|
||||||
export const updateEnvelopeRecipientsMeta: TrpcRouteMeta = {
|
export const updateEnvelopeRecipientsMeta: TrpcRouteMeta = {
|
||||||
@@ -19,19 +15,9 @@ export const updateEnvelopeRecipientsMeta: TrpcRouteMeta = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ZUpdateEnvelopeRecipientSchema = z.object({
|
|
||||||
id: z.number().describe('The ID of the recipient to update.'),
|
|
||||||
email: ZRecipientEmailSchema,
|
|
||||||
name: z.string().max(255).optional(),
|
|
||||||
role: z.nativeEnum(RecipientRole).optional(),
|
|
||||||
signingOrder: z.number().optional(),
|
|
||||||
accessAuth: z.array(ZRecipientAccessAuthTypesSchema).default([]).optional(),
|
|
||||||
actionAuth: z.array(ZRecipientActionAuthTypesSchema).default([]).optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const ZUpdateEnvelopeRecipientsRequestSchema = z.object({
|
export const ZUpdateEnvelopeRecipientsRequestSchema = z.object({
|
||||||
envelopeId: z.string(),
|
envelopeId: z.string(),
|
||||||
data: ZUpdateEnvelopeRecipientSchema.array(),
|
data: ZUpdateRecipientSchema.array(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ZUpdateEnvelopeRecipientsResponseSchema = z.object({
|
export const ZUpdateEnvelopeRecipientsResponseSchema = z.object({
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { EnvelopeType, RecipientRole } from '@prisma/client';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-auth';
|
import { ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-auth';
|
||||||
import { ZRecipientEmailSchema, ZRecipientLiteSchema } from '@documenso/lib/types/recipient';
|
import { ZRecipientLiteSchema } from '@documenso/lib/types/recipient';
|
||||||
|
|
||||||
export const ZSetEnvelopeRecipientsRequestSchema = z.object({
|
export const ZSetEnvelopeRecipientsRequestSchema = z.object({
|
||||||
envelopeId: z.string(),
|
envelopeId: z.string(),
|
||||||
@@ -10,7 +10,7 @@ export const ZSetEnvelopeRecipientsRequestSchema = z.object({
|
|||||||
recipients: z.array(
|
recipients: z.array(
|
||||||
z.object({
|
z.object({
|
||||||
id: z.number().optional(),
|
id: z.number().optional(),
|
||||||
email: ZRecipientEmailSchema,
|
email: z.string().toLowerCase().email().min(1).max(254),
|
||||||
name: z.string().max(255),
|
name: z.string().max(255),
|
||||||
role: z.nativeEnum(RecipientRole),
|
role: z.nativeEnum(RecipientRole),
|
||||||
signingOrder: z.number().optional(),
|
signingOrder: z.number().optional(),
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
} from '@documenso/lib/types/document-meta';
|
} from '@documenso/lib/types/document-meta';
|
||||||
import { ZEnvelopeAttachmentTypeSchema } from '@documenso/lib/types/envelope-attachment';
|
import { ZEnvelopeAttachmentTypeSchema } from '@documenso/lib/types/envelope-attachment';
|
||||||
import { ZFieldMetaPrefillFieldsSchema } from '@documenso/lib/types/field-meta';
|
import { ZFieldMetaPrefillFieldsSchema } from '@documenso/lib/types/field-meta';
|
||||||
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
|
|
||||||
|
|
||||||
import { zodFormData } from '../../utils/zod-form-data';
|
import { zodFormData } from '../../utils/zod-form-data';
|
||||||
import type { TrpcRouteMeta } from '../trpc';
|
import type { TrpcRouteMeta } from '../trpc';
|
||||||
@@ -41,7 +40,7 @@ export const ZUseEnvelopePayloadSchema = z.object({
|
|||||||
.array(
|
.array(
|
||||||
z.object({
|
z.object({
|
||||||
id: z.number().describe('The ID of the recipient in the template.'),
|
id: z.number().describe('The ID of the recipient in the template.'),
|
||||||
email: ZRecipientEmailSchema,
|
email: z.string().email().max(254),
|
||||||
name: z.string().max(255).optional(),
|
name: z.string().max(255).optional(),
|
||||||
signingOrder: z.number().optional(),
|
signingOrder: z.number().optional(),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { getHighestOrganisationRoleInGroup } from '@documenso/lib/utils/organisations';
|
import { getHighestOrganisationRoleInGroup } from '@documenso/lib/utils/organisations';
|
||||||
import { buildTeamWhereQuery, getHighestTeamRoleInGroup } from '@documenso/lib/utils/teams';
|
import {
|
||||||
|
buildTeamWhereQuery,
|
||||||
|
extractDerivedTeamSettings,
|
||||||
|
getHighestTeamRoleInGroup,
|
||||||
|
} from '@documenso/lib/utils/teams';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
import { authenticatedProcedure } from '../trpc';
|
import { authenticatedProcedure } from '../trpc';
|
||||||
@@ -30,6 +34,7 @@ export const getOrganisationSession = async ({
|
|||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
organisationClaim: true,
|
organisationClaim: true,
|
||||||
|
organisationGlobalSettings: true,
|
||||||
subscription: true,
|
subscription: true,
|
||||||
groups: {
|
groups: {
|
||||||
where: {
|
where: {
|
||||||
@@ -45,6 +50,7 @@ export const getOrganisationSession = async ({
|
|||||||
teams: {
|
teams: {
|
||||||
where: buildTeamWhereQuery({ teamId: undefined, userId }),
|
where: buildTeamWhereQuery({ teamId: undefined, userId }),
|
||||||
include: {
|
include: {
|
||||||
|
teamGlobalSettings: true,
|
||||||
teamGroups: {
|
teamGroups: {
|
||||||
where: {
|
where: {
|
||||||
organisationGroup: {
|
organisationGroup: {
|
||||||
@@ -67,12 +73,24 @@ export const getOrganisationSession = async ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return organisations.map((organisation) => {
|
return organisations.map((organisation) => {
|
||||||
|
const { organisationGlobalSettings } = organisation;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...organisation,
|
...organisation,
|
||||||
teams: organisation.teams.map((team) => ({
|
teams: organisation.teams.map((team) => {
|
||||||
...team,
|
const derivedSettings = extractDerivedTeamSettings(
|
||||||
currentTeamRole: getHighestTeamRoleInGroup(team.teamGroups),
|
organisationGlobalSettings,
|
||||||
})),
|
team.teamGlobalSettings,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...team,
|
||||||
|
currentTeamRole: getHighestTeamRoleInGroup(team.teamGroups),
|
||||||
|
preferences: {
|
||||||
|
aiFeaturesEnabled: derivedSettings.aiFeaturesEnabled,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
currentOrganisationRole: getHighestOrganisationRoleInGroup(organisation.groups),
|
currentOrganisationRole: getHighestOrganisationRoleInGroup(organisation.groups),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ export const ZGetOrganisationSessionResponseSchema = ZOrganisationSchema.extend(
|
|||||||
organisationId: true,
|
organisationId: true,
|
||||||
}).extend({
|
}).extend({
|
||||||
currentTeamRole: z.nativeEnum(TeamMemberRole),
|
currentTeamRole: z.nativeEnum(TeamMemberRole),
|
||||||
|
preferences: z.object({
|
||||||
|
aiFeaturesEnabled: z.boolean(),
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
subscription: SubscriptionSchema.nullable(),
|
subscription: SubscriptionSchema.nullable(),
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
|
|||||||
emailReplyTo,
|
emailReplyTo,
|
||||||
// emailReplyToName,
|
// emailReplyToName,
|
||||||
emailDocumentSettings,
|
emailDocumentSettings,
|
||||||
|
|
||||||
|
// AI features settings.
|
||||||
|
aiFeaturesEnabled,
|
||||||
} = data;
|
} = data;
|
||||||
|
|
||||||
if (Object.values(data).length === 0) {
|
if (Object.values(data).length === 0) {
|
||||||
@@ -149,6 +152,9 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
|
|||||||
emailReplyTo,
|
emailReplyTo,
|
||||||
// emailReplyToName,
|
// emailReplyToName,
|
||||||
emailDocumentSettings,
|
emailDocumentSettings,
|
||||||
|
|
||||||
|
// AI features settings.
|
||||||
|
aiFeaturesEnabled,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ export const ZUpdateOrganisationSettingsRequestSchema = z.object({
|
|||||||
emailReplyTo: z.string().email().nullish(),
|
emailReplyTo: z.string().email().nullish(),
|
||||||
// emailReplyToName: z.string().optional(),
|
// emailReplyToName: z.string().optional(),
|
||||||
emailDocumentSettings: ZDocumentEmailSettingsSchema.optional(),
|
emailDocumentSettings: ZDocumentEmailSettingsSchema.optional(),
|
||||||
|
|
||||||
|
// AI features settings.
|
||||||
|
aiFeaturesEnabled: z.boolean().optional(),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user