Compare commits

..

4 Commits

Author SHA1 Message Date
copilot-swe-agent[bot] e74c2c708c Initial plan 2025-12-02 08:13:23 +00:00
David Nguyen 7849ffbc2d fix: issue
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-02 19:11:13 +11:00
David Nguyen 0490fd78b1 fix: issue
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-02 19:05:34 +11:00
David Nguyen 16481bc34e feat: add empty emails for envelopes 2025-12-02 18:52:02 +11:00
126 changed files with 1387 additions and 5061 deletions
+1 -10
View File
@@ -147,15 +147,6 @@ NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS=false
# We only collect: app version, installation ID, and node ID. No personal data is collected.
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_TEST_AUTHENTICATE_USERNAME="Test User"
E2E_TEST_AUTHENTICATE_USER_EMAIL="testuser@mail.com"
@@ -166,4 +157,4 @@ E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_Password123"
NEXT_PRIVATE_LOGGER_FILE_PATH=
# [[PLAIN SUPPORT]]
NEXT_PRIVATE_PLAIN_API_KEY=
NEXT_PRIVATE_PLAIN_API_KEY=
-3
View File
@@ -60,6 +60,3 @@ CLAUDE.md
# agents
.specs
# scripts
scripts/output*
+3
View File
@@ -1,3 +1,6 @@
> 🚨 🚨 🚨
> Documenso 2.0.0 is live on Product Hunt 🎉 <a href="https://documen.so/launch" target="_blank" rel="noopener noreferrer" style="text-decoration: underline;">Join us to celebrate the best Documenso yet 🪩</a>
<img src="https://github.com/documenso/documenso/assets/13398220/a643571f-0239-46a6-a73e-6bef38d1228b" alt="Documenso Logo">
<p align="center" style="margin-top: 20px">
+1 -1
View File
@@ -15,7 +15,7 @@
"@documenso/tailwind-config": "*",
"@documenso/trpc": "*",
"@documenso/ui": "*",
"next": "^15.5.7",
"next": "^15",
"next-plausible": "^3.12.5",
"nextra": "^3",
"nextra-theme-docs": "^3",
@@ -4,5 +4,4 @@ export default {
'how-to': 'How To',
'setting-up-oauth-providers': 'Setting up OAuth Providers',
telemetry: 'Telemetry',
'ai-features': 'AI Recipient & Field Detection',
};
@@ -1,72 +0,0 @@
---
title: AI Recipient & Field Detection (Self-hosting)
description: Configure Google Vertex AI so Documenso can detect recipients and fields automatically.
---
import { Callout, Steps } from 'nextra/components';
# AI Recipient & Field Detection (Self-hosting)
This guide covers how to enable the AI recipient and field detection features when you self-host Documenso.
## What this enables
- Detect recipients from uploaded PDFs (roles, names, emails when present).
- Detect and place fields (signature, initials, name, email, date, text, number, radio, checkbox) onto draft envelopes.
- Built-in rate limits (3 requests per minute per IP) to prevent abuse.
## Prerequisites
- A Google Cloud project with the **Vertex AI API** enabled and billing active.
- A **Vertex AI Express API key** with access to Gemini models (create via the [Vertex AI Express flow](https://cloud.google.com/vertex-ai/generative-ai/docs/start/express-mode/overview) and manage keys in [API keys](https://cloud.google.com/vertex-ai/generative-ai/docs/start/api-keys)).
- Documenso version that includes the AI detection feature and the corresponding database migration.
## Configure environment variables
Add these variables to your deployment `.env` (or secret manager):
```
GOOGLE_VERTEX_PROJECT_ID="<your-gcp-project-id>"
GOOGLE_VERTEX_API_KEY="<your-vertex-api-key>"
# Optional, defaults to "global"
GOOGLE_VERTEX_LOCATION="global"
```
<Callout type="info">
Use a region close to your users if you need data residency considerations (e.g. `europe-west1`).
If you omit the location, Documenso uses `global`. Not all models are available in every region;
if a model is unavailable, switch to a supported region.
</Callout>
## Deploy with the published container
- Use the official Documenso image (DockerHub or GHCR) and supply the Vertex env vars above.
- Ensure migrations run on startup (the container runs `prisma migrate deploy` in production mode).
- Restart the container after adding or changing Vertex env vars.
## Enable the feature in Documenso
Once the service is running with the Vertex env vars:
<Steps>
### Organisation settings
Go to **Settings → Document Preferences → AI Features** and set to **Enabled**. Teams that inherit organisation defaults will pick this up.
### Team settings
If a team overrides organisation defaults, go to **Team Settings → Document Preferences → AI Features** and choose **Enabled** (or **Inherit** to follow the organisation).
### Verify in the editor
Open a draft envelope. In **Recipients**, you should see the sparkle button for AI detection. In **Fields**, you should see **Detect with AI** available.
</Steps>
## Troubleshooting
- **Too many requests**: Wait a minute or two and retry (rate limit is 3/min per IP).
- **AI options hidden**: Ensure the env vars are set, the server was restarted after setting them, and `aiFeaturesEnabled` is enabled at organisation/team level.
- **Detection fails immediately**: Confirm the Vertex API key is valid and the project has Vertex AI enabled. Check server logs for status codes from Vertex.
If issues persist, recheck env vars, restart the service, and confirm the Prisma migration was applied.
@@ -119,8 +119,6 @@ NEXT_PRIVATE_SMTP_USERNAME="<your-username>"
NEXT_PRIVATE_SMTP_PASSWORD="<your-password>"
```
For full AI setup details (including model availability notes), see the [AI Recipient & Field Detection (Self-hosting)](./ai-features) page.
### Set Up Your Signing Certificate
<Callout type="warning">
@@ -269,63 +267,58 @@ You can access the Documenso application by visiting the URL you provided for th
The environment variables listed above are a subset of those available for configuring Documenso. The table below provides a complete list of environment variables and their descriptions.
For AI setup specifics, see the [AI Recipient & Field Detection (Self-hosting)](./ai-features) page.
| Variable | Description |
| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `PORT` | The port on which the Documenso application runs. It defaults to `3000`. |
| `NEXTAUTH_SECRET` | The secret key used by NextAuth.js for encryption and signing. |
| `NEXT_PRIVATE_ENCRYPTION_KEY` | The primary encryption key for symmetric encryption and decryption (at least 32 characters). |
| `NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY` | The secondary encryption key for symmetric encryption and decryption (at least 32 characters). |
| `NEXT_PRIVATE_GOOGLE_CLIENT_ID` | The Google client ID for Google authentication (optional). |
| `NEXT_PRIVATE_GOOGLE_CLIENT_SECRET` | The Google client secret for Google authentication (optional). |
| `NEXT_PRIVATE_MICROSOFT_CLIENT_ID` | The Microsoft client ID for Microsoft authentication (optional). |
| `NEXT_PRIVATE_MICROSOFT_CLIENT_SECRET` | The Microsoft client secret for Microsoft authentication (optional). |
| `NEXT_PRIVATE_OIDC_CLIENT_ID` | The OIDC client ID for OIDC authentication (optional). |
| `NEXT_PRIVATE_OIDC_CLIENT_SECRET` | The OIDC client secret for OIDC authentication (optional). |
| `NEXT_PRIVATE_OIDC_WELL_KNOWN` | The well-known URL for the OIDC provider (optional). |
| `NEXT_PRIVATE_OIDC_PROVIDER_LABEL` | The label to display for the OIDC provider button (optional). |
| `NEXT_PRIVATE_OIDC_SKIP_VERIFY` | Whether to skip email verification for OIDC accounts (optional, default `false`). |
| `NEXT_PUBLIC_WEBAPP_URL` | The URL for the web application. |
| `NEXT_PUBLIC_SUPPORT_EMAIL` | The support email address displayed to users (default `support@documenso.com`). |
| `NEXT_PRIVATE_DATABASE_URL` | The URL for the primary database connection (with connection pooling). |
| `NEXT_PRIVATE_DIRECT_DATABASE_URL` | The URL for the direct database connection (without connection pooling). |
| `NEXT_PRIVATE_SIGNING_TRANSPORT` | The signing transport to use. Available options: local (default) |
| `NEXT_PRIVATE_SIGNING_PASSPHRASE` | The passphrase for the key file. |
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS` | The base64-encoded contents of the key file will be used instead of the file path. |
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` | The path to the key file, default `/opt/documenso/cert.p12`. |
| `NEXT_PUBLIC_UPLOAD_TRANSPORT` | The transport for file uploads (database or s3). |
| `NEXT_PRIVATE_UPLOAD_ENDPOINT` | The endpoint for the S3 storage transport (for third-party S3-compatible providers). |
| `NEXT_PRIVATE_UPLOAD_FORCE_PATH_STYLE` | Whether to force path-style URLs for the S3 storage transport. |
| `NEXT_PRIVATE_UPLOAD_REGION` | The region for the S3 storage transport (defaults to us-east-1). |
| `NEXT_PRIVATE_UPLOAD_BUCKET` | The bucket to use for the S3 storage transport. |
| `NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID` | The access key ID for the S3 storage transport. |
| `NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY` | The secret access key for the S3 storage transport. |
| `NEXT_PRIVATE_SMTP_TRANSPORT` | The transport to send emails (smtp-auth, smtp-api, resend, or mailchannels). |
| `NEXT_PRIVATE_SMTP_HOST` | The host for the SMTP server for SMTP transports. |
| `NEXT_PRIVATE_SMTP_PORT` | The port for the SMTP server for SMTP transports. |
| `NEXT_PRIVATE_SMTP_USERNAME` | The username for the SMTP server for the `smtp-auth` transport. |
| `NEXT_PRIVATE_SMTP_PASSWORD` | The password for the SMTP server for the `smtp-auth` transport. |
| `NEXT_PRIVATE_SMTP_APIKEY_USER` | The API key user for the SMTP server for the `smtp-api` transport. |
| `NEXT_PRIVATE_SMTP_APIKEY` | The API key for the SMTP server for the `smtp-api` transport. |
| `NEXT_PRIVATE_SMTP_SECURE` | Whether to force the use of TLS for the SMTP server for SMTP transports. |
| `NEXT_PRIVATE_SMTP_UNSAFE_IGNORE_TLS` | Whether to ignore TLS errors for the SMTP server (useful for self-signed certificates). |
| `NEXT_PRIVATE_SMTP_FROM_ADDRESS` | The email address for the "from" address. |
| `NEXT_PRIVATE_SMTP_FROM_NAME` | The sender name for the "from" address. |
| `NEXT_PRIVATE_RESEND_API_KEY` | The API key for Resend.com for the `resend` transport. |
| `NEXT_PRIVATE_MAILCHANNELS_API_KEY` | The optional API key for MailChannels (if using a proxy) for the `mailchannels` transport. |
| `NEXT_PRIVATE_MAILCHANNELS_ENDPOINT` | The optional endpoint for the MailChannels API (if using a proxy) for the `mailchannels` transport. |
| `NEXT_PRIVATE_MAILCHANNELS_DKIM_DOMAIN` | The domain for DKIM signing with MailChannels for the `mailchannels` transport. |
| `NEXT_PRIVATE_MAILCHANNELS_DKIM_SELECTOR` | The selector for DKIM signing with MailChannels for the `mailchannels` transport. |
| `NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY` | The private key for DKIM signing with MailChannels for the `mailchannels` transport. |
| `NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT` | The maximum document upload limit displayed to the user (in MB). |
| `NEXT_PUBLIC_POSTHOG_KEY` | The optional PostHog key for analytics and feature flags. |
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Whether to disable user signups through the /signup page. |
| `NEXT_PRIVATE_BROWSERLESS_URL` | The URL for a Browserless.io instance to generate PDFs (optional). |
| `DOCUMENSO_DISABLE_TELEMETRY` | Set to `true` to disable anonymous telemetry (see [Telemetry](#telemetry) section below). |
| `GOOGLE_VERTEX_PROJECT_ID` | Google Cloud project ID used for Vertex AI (required for AI detection). |
| `GOOGLE_VERTEX_API_KEY` | Vertex AI Express API key with access to Gemini models (required for AI detection). See [AI Recipient & Field Detectionfor](./ai-features) for details. |
| `GOOGLE_VERTEX_LOCATION` | Optional Vertex region, defaults to `global`. Not all models are available in every region. |
| Variable | Description |
| -------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| `PORT` | The port on which the Documenso application runs. It defaults to `3000`. |
| `NEXTAUTH_SECRET` | The secret key used by NextAuth.js for encryption and signing. |
| `NEXT_PRIVATE_ENCRYPTION_KEY` | The primary encryption key for symmetric encryption and decryption (at least 32 characters). |
| `NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY` | The secondary encryption key for symmetric encryption and decryption (at least 32 characters). |
| `NEXT_PRIVATE_GOOGLE_CLIENT_ID` | The Google client ID for Google authentication (optional). |
| `NEXT_PRIVATE_GOOGLE_CLIENT_SECRET` | The Google client secret for Google authentication (optional). |
| `NEXT_PRIVATE_MICROSOFT_CLIENT_ID` | The Microsoft client ID for Microsoft authentication (optional). |
| `NEXT_PRIVATE_MICROSOFT_CLIENT_SECRET` | The Microsoft client secret for Microsoft authentication (optional). |
| `NEXT_PRIVATE_OIDC_CLIENT_ID` | The OIDC client ID for OIDC authentication (optional). |
| `NEXT_PRIVATE_OIDC_CLIENT_SECRET` | The OIDC client secret for OIDC authentication (optional). |
| `NEXT_PRIVATE_OIDC_WELL_KNOWN` | The well-known URL for the OIDC provider (optional). |
| `NEXT_PRIVATE_OIDC_PROVIDER_LABEL` | The label to display for the OIDC provider button (optional). |
| `NEXT_PRIVATE_OIDC_SKIP_VERIFY` | Whether to skip email verification for OIDC accounts (optional, default `false`). |
| `NEXT_PUBLIC_WEBAPP_URL` | The URL for the web application. |
| `NEXT_PUBLIC_SUPPORT_EMAIL` | The support email address displayed to users (default `support@documenso.com`). |
| `NEXT_PRIVATE_DATABASE_URL` | The URL for the primary database connection (with connection pooling). |
| `NEXT_PRIVATE_DIRECT_DATABASE_URL` | The URL for the direct database connection (without connection pooling). |
| `NEXT_PRIVATE_SIGNING_TRANSPORT` | The signing transport to use. Available options: local (default) |
| `NEXT_PRIVATE_SIGNING_PASSPHRASE` | The passphrase for the key file. |
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS` | The base64-encoded contents of the key file will be used instead of the file path. |
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` | The path to the key file, default `/opt/documenso/cert.p12`. |
| `NEXT_PUBLIC_UPLOAD_TRANSPORT` | The transport for file uploads (database or s3). |
| `NEXT_PRIVATE_UPLOAD_ENDPOINT` | The endpoint for the S3 storage transport (for third-party S3-compatible providers). |
| `NEXT_PRIVATE_UPLOAD_FORCE_PATH_STYLE` | Whether to force path-style URLs for the S3 storage transport. |
| `NEXT_PRIVATE_UPLOAD_REGION` | The region for the S3 storage transport (defaults to us-east-1). |
| `NEXT_PRIVATE_UPLOAD_BUCKET` | The bucket to use for the S3 storage transport. |
| `NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID` | The access key ID for the S3 storage transport. |
| `NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY` | The secret access key for the S3 storage transport. |
| `NEXT_PRIVATE_SMTP_TRANSPORT` | The transport to send emails (smtp-auth, smtp-api, resend, or mailchannels). |
| `NEXT_PRIVATE_SMTP_HOST` | The host for the SMTP server for SMTP transports. |
| `NEXT_PRIVATE_SMTP_PORT` | The port for the SMTP server for SMTP transports. |
| `NEXT_PRIVATE_SMTP_USERNAME` | The username for the SMTP server for the `smtp-auth` transport. |
| `NEXT_PRIVATE_SMTP_PASSWORD` | The password for the SMTP server for the `smtp-auth` transport. |
| `NEXT_PRIVATE_SMTP_APIKEY_USER` | The API key user for the SMTP server for the `smtp-api` transport. |
| `NEXT_PRIVATE_SMTP_APIKEY` | The API key for the SMTP server for the `smtp-api` transport. |
| `NEXT_PRIVATE_SMTP_SECURE` | Whether to force the use of TLS for the SMTP server for SMTP transports. |
| `NEXT_PRIVATE_SMTP_UNSAFE_IGNORE_TLS` | Whether to ignore TLS errors for the SMTP server (useful for self-signed certificates). |
| `NEXT_PRIVATE_SMTP_FROM_ADDRESS` | The email address for the "from" address. |
| `NEXT_PRIVATE_SMTP_FROM_NAME` | The sender name for the "from" address. |
| `NEXT_PRIVATE_RESEND_API_KEY` | The API key for Resend.com for the `resend` transport. |
| `NEXT_PRIVATE_MAILCHANNELS_API_KEY` | The optional API key for MailChannels (if using a proxy) for the `mailchannels` transport. |
| `NEXT_PRIVATE_MAILCHANNELS_ENDPOINT` | The optional endpoint for the MailChannels API (if using a proxy) for the `mailchannels` transport. |
| `NEXT_PRIVATE_MAILCHANNELS_DKIM_DOMAIN` | The domain for DKIM signing with MailChannels for the `mailchannels` transport. |
| `NEXT_PRIVATE_MAILCHANNELS_DKIM_SELECTOR` | The selector for DKIM signing with MailChannels for the `mailchannels` transport. |
| `NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY` | The private key for DKIM signing with MailChannels for the `mailchannels` transport. |
| `NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT` | The maximum document upload limit displayed to the user (in MB). |
| `NEXT_PUBLIC_POSTHOG_KEY` | The optional PostHog key for analytics and feature flags. |
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Whether to disable user signups through the /signup page. |
| `NEXT_PRIVATE_BROWSERLESS_URL` | The URL for a Browserless.io instance to generate PDFs (optional). |
| `DOCUMENSO_DISABLE_TELEMETRY` | Set to `true` to disable anonymous telemetry (see [Telemetry](#telemetry) section below). |
## Telemetry
@@ -4,5 +4,4 @@ export default {
'document-visibility': 'Document Visibility',
fields: 'Document Fields',
'email-preferences': 'Email Preferences',
'ai-detection': 'AI Recipient & Field Detection',
};
@@ -1,68 +0,0 @@
---
title: AI Recipient & Field Detection
description: Use Documensos AI helpers to detect recipients and fields in draft documents.
---
# AI Recipient & Field Detection
Documenso can suggest recipients and place fields automatically using Google Vertex AI (Gemini). The feature is optional and only available when your organisation or team has **AI Features** enabled. Documents are processed securely and providers do not retain your data for training.
## Requirements
- AI Features must be enabled in **Document Preferences** for your organisation or team.
- The envelope must be in **Draft** status.
- Helpful rate limits are in place (up to 3 detection requests per minute per IP) to prevent abuse. If you see a “too many requests” message, wait a minute or two and try again.
### Enable AI features
1. **Organisation settings**:
Settings → Document Preferences → **AI Features** → Enabled.
_This applies to teams that inherit organisation defaults._
2. **Team settings**:
Team Settings → Document Preferences → **AI Features** → choose Enabled, Disabled, or Inherit.
## Detect recipients
Use this to identify who needs to sign or approve.
1. Open a draft document/template and go to the **Recipients** panel.
2. Select the **sparkle** button to start detection. If AI is enabled, uploads launched from the dashboard will open the detector automatically.
![Detect recipients with AI button in the Recipients panel](/document-signing/ai-recipient-detect-button.webp)
3. Wait for progress to finish, then review the suggested recipients.
4. Remove any incorrect entries, then **Add recipients** to apply them. Existing recipients and duplicates are preserved.
Notes:
- Detection is unavailable once an envelope is completed.
- You can re-run detection if you update the document; each run counts toward the rate limit.
## Detect fields
Use this to auto-place fields on the pages of a draft.
1. Open the envelope editor and switch to the **Fields** tab.
2. Select **Detect with AI**. Provide optional context (e.g., “Alice is the tenant, Bob is the landlord”) to improve recipient assignment.
![AI field detection dialog with context input](/document-signing/ai-field-detection-button.webp)
![AI field detection dialog with context input](/document-signing/ai-field-detection-dialog.webp)
3. Watch the progress indicators; they update per page and total fields found.
4. Review the summary and choose **Add fields** to place them in the editor.
Notes:
- Works only for draft envelopes and teams with AI features enabled.
- Existing fields are masked during detection to avoid duplicates.
- Fields are assigned to recipients based on nearby labels and your context message; you can edit them after adding.
## Best practices
- Keep labels near the intended fields (e.g., “Tenant signature”, “Buyer email”).
- Provide short context when roles are ambiguous.
- Always review suggestions before sending; AI assists but does not replace final checks.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 466 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 370 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 254 KiB

+1 -1
View File
@@ -12,7 +12,7 @@
"dependencies": {
"@documenso/prisma": "*",
"luxon": "^3.7.2",
"next": "^15.5.7"
"next": "^15"
},
"devDependencies": {
"@types/node": "^20",
@@ -45,7 +45,7 @@ export const AdminDocumentDeleteDialog = ({ envelopeId }: AdminDocumentDeleteDia
toast({
title: _(msg`Document deleted`),
description: _(msg`The Document has been deleted successfully.`),
description: 'The Document has been deleted successfully.',
duration: 5000,
});
@@ -54,9 +54,8 @@ export const AdminDocumentDeleteDialog = ({ envelopeId }: AdminDocumentDeleteDia
toast({
title: _(msg`An unknown error occurred`),
variant: 'destructive',
description: _(
msg`We encountered an unknown error while attempting to delete your document. Please try again later.`,
),
description:
'We encountered an unknown error while attempting to delete your document. Please try again later.',
});
}
};
@@ -1,368 +0,0 @@
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>
);
};
@@ -1,361 +0,0 @@
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,6 +19,7 @@ import * as z from 'zod';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { trpc, trpc as trpcReact } from '@documenso/trpc/react';
import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper';
import { cn } from '@documenso/ui/lib/utils';
@@ -129,18 +130,44 @@ export const EnvelopeDistributeDialog = ({
const distributionMethod = watch('meta.distributionMethod');
const recipientsWithIndex = useMemo(
() =>
envelope.recipients.map((recipient, index) => ({
...recipient,
index,
})),
[envelope.recipients],
);
const recipientsMissingSignatureFields = useMemo(
() =>
envelope.recipients.filter(
recipientsWithIndex.filter(
(recipient) =>
recipient.role === RecipientRole.SIGNER &&
!envelope.fields.some(
(field) => field.type === FieldType.SIGNATURE && field.recipientId === recipient.id,
),
),
[envelope.recipients, envelope.fields],
[recipientsWithIndex, 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(() => {
if (recipientsMissingSignatureFields.length > 0) {
return 'MISSING_SIGNATURES';
@@ -150,8 +177,12 @@ export const EnvelopeDistributeDialog = ({
return 'MISSING_RECIPIENTS';
}
if (recipientsMissingRequiredEmail.length > 0) {
return 'MISSING_REQUIRED_EMAIL';
}
return null;
}, [envelope.recipients, envelope.fields, recipientsMissingSignatureFields]);
}, [envelope.recipients, recipientsMissingRequiredEmail, recipientsMissingSignatureFields]);
const onFormSubmit = async ({ meta }: TEnvelopeDistributeFormSchema) => {
try {
@@ -444,7 +475,22 @@ export const EnvelopeDistributeDialog = ({
<ul className="ml-2 mt-1 list-inside list-disc">
{recipientsMissingSignatureFields.map((recipient) => (
<li key={recipient.id}>{recipient.email}</li>
<li key={recipient.id}>
{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>
</AlertDescription>
@@ -103,8 +103,8 @@ export const TemplateBulkSendDialog = ({
console.error(err);
toast({
title: _(msg`Error`),
description: _(msg`Failed to upload CSV. Please check the file format and try again.`),
title: 'Error',
description: 'Failed to upload CSV. Please check the file format and try again.',
variant: 'destructive',
});
}
@@ -21,6 +21,7 @@ import {
SKIP_QUERY_BATCH_META,
} from '@documenso/lib/constants/trpc';
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 { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
@@ -65,7 +66,7 @@ const ZAddRecipientsForNewDocumentSchema = z.object({
recipients: z.array(
z.object({
id: z.number(),
email: z.string().email(),
email: ZRecipientEmailSchema,
name: z.string(),
signingOrder: z.number().optional(),
}),
@@ -349,7 +350,7 @@ export function TemplateUseDialog({
{documentDistributionMethod === DocumentDistributionMethod.EMAIL && (
<label
className="text-muted-foreground ml-2 flex items-center text-sm"
className="ml-2 flex items-center text-sm text-muted-foreground"
htmlFor="distributeDocument"
>
<Trans>Send document</Trans>
@@ -358,7 +359,7 @@ export function TemplateUseDialog({
<InfoIcon className="mx-1 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4">
<TooltipContent className="z-[99999] max-w-md space-y-2 p-4 text-muted-foreground">
<p>
<Trans>
The document will be immediately sent to recipients if this
@@ -378,7 +379,7 @@ export function TemplateUseDialog({
{documentDistributionMethod === DocumentDistributionMethod.NONE && (
<label
className="text-muted-foreground ml-2 flex items-center text-sm"
className="ml-2 flex items-center text-sm text-muted-foreground"
htmlFor="distributeDocument"
>
<Trans>Create as pending</Trans>
@@ -386,7 +387,7 @@ export function TemplateUseDialog({
<TooltipTrigger type="button">
<InfoIcon className="mx-1 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4">
<TooltipContent className="z-[99999] max-w-md space-y-2 p-4 text-muted-foreground">
<p>
<Trans>
Create the document as pending and ready to sign.
@@ -432,7 +433,7 @@ export function TemplateUseDialog({
}}
/>
<label
className="text-muted-foreground ml-2 flex items-center text-sm"
className="ml-2 flex items-center text-sm text-muted-foreground"
htmlFor="useCustomDocument"
>
<Trans>Upload custom document</Trans>
@@ -440,7 +441,7 @@ export function TemplateUseDialog({
<TooltipTrigger type="button">
<InfoIcon className="mx-1 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4">
<TooltipContent className="z-[99999] max-w-md space-y-2 p-4 text-muted-foreground">
<p>
<Trans>
Upload a custom document to use instead of the template's default
@@ -470,19 +471,19 @@ export function TemplateUseDialog({
<FormControl>
<div
key={item.id}
className="border-border bg-card hover:bg-accent/10 flex items-center gap-4 rounded-lg border p-4 transition-colors"
className="flex items-center gap-4 rounded-lg border border-border bg-card p-4 transition-colors hover:bg-accent/10"
>
<div className="flex-shrink-0">
<div className="bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg">
<FileTextIcon className="text-primary h-5 w-5" />
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<FileTextIcon className="h-5 w-5 text-primary" />
</div>
</div>
<div className="min-w-0 flex-1">
<h4 className="text-foreground truncate text-sm font-medium">
<h4 className="truncate text-sm font-medium text-foreground">
{item.title}
</h4>
<p className="text-muted-foreground mt-0.5 text-xs">
<p className="mt-0.5 text-xs text-muted-foreground">
{field.value ? (
<div>
<Trans>
@@ -5,6 +5,7 @@ import {
ZDocumentMetaDateFormatSchema,
ZDocumentMetaLanguageSchema,
} from '@documenso/lib/types/document-meta';
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
import { DocumentDistributionMethod } from '@documenso/prisma/generated/types';
// Define the schema for configuration
@@ -55,7 +56,7 @@ export const ZConfigureTemplateEmbedFormSchema = ZConfigureEmbedFormSchema.exten
nativeId: z.number().optional(),
formId: z.string(),
name: z.string(),
email: z.union([z.string().length(0), z.string().email('Invalid email address')]),
email: ZRecipientEmailSchema,
role: z.enum(['SIGNER', 'CC', 'APPROVER', 'VIEWER', 'ASSISTANT']),
signingOrder: z.number().optional(),
disabled: z.boolean().optional(),
@@ -1,6 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { EnvelopeItem, FieldType } from '@prisma/client';
@@ -230,8 +229,8 @@ export const ConfigureFieldsView = ({
setFieldClipboard(lastActiveField);
toast({
title: _(msg`Copied field`),
description: _(msg`Copied field to clipboard`),
title: 'Copied field',
description: 'Copied field to clipboard',
});
}
},
@@ -150,8 +150,8 @@ export const MultiSignDocumentSigningView = ({
onDocumentError?.();
toast({
title: _(msg`Error`),
description: _(msg`Failed to complete the document. Please try again.`),
title: 'Error',
description: 'Failed to complete the document. Please try again.',
variant: 'destructive',
});
} finally {
@@ -58,7 +58,6 @@ export type TDocumentPreferencesFormSchema = {
includeSigningCertificate: boolean | null;
includeAuditLog: boolean | null;
signatureTypes: DocumentSignatureType[];
aiFeaturesEnabled: boolean | null;
};
type SettingsSubset = Pick<
@@ -73,13 +72,11 @@ type SettingsSubset = Pick<
| 'typedSignatureEnabled'
| 'uploadSignatureEnabled'
| 'drawSignatureEnabled'
| 'aiFeaturesEnabled'
>;
export type DocumentPreferencesFormProps = {
settings: SettingsSubset;
canInherit: boolean;
isAiFeaturesConfigured?: boolean;
onFormSubmit: (data: TDocumentPreferencesFormSchema) => Promise<void>;
};
@@ -87,7 +84,6 @@ export const DocumentPreferencesForm = ({
settings,
onFormSubmit,
canInherit,
isAiFeaturesConfigured = false,
}: DocumentPreferencesFormProps) => {
const { t } = useLingui();
const { user, organisations } = useSession();
@@ -109,7 +105,6 @@ export const DocumentPreferencesForm = ({
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(canInherit ? 0 : 1, {
message: msg`At least one signature type must be enabled`.id,
}),
aiFeaturesEnabled: z.boolean().nullable(),
});
const form = useForm<TDocumentPreferencesFormSchema>({
@@ -125,7 +120,6 @@ export const DocumentPreferencesForm = ({
includeSigningCertificate: settings.includeSigningCertificate,
includeAuditLog: settings.includeAuditLog,
signatureTypes: extractTeamSignatureSettings({ ...settings }),
aiFeaturesEnabled: settings.aiFeaturesEnabled,
},
resolver: zodResolver(ZDocumentPreferencesFormSchema),
});
@@ -318,7 +312,7 @@ export const DocumentPreferencesForm = ({
}))}
selectedValues={field.value}
onChange={field.onChange}
className="w-full bg-background"
className="bg-background w-full"
enableSearch={false}
emptySelectionPlaceholder={
canInherit ? t`Inherit from organisation` : t`Select signature types`
@@ -384,7 +378,7 @@ export const DocumentPreferencesForm = ({
</FormControl>
<div className="pt-2">
<div className="text-xs font-medium text-muted-foreground">
<div className="text-muted-foreground text-xs font-medium">
<Trans>Preview</Trans>
</div>
@@ -515,59 +509,6 @@ export const DocumentPreferencesForm = ({
)}
/>
{isAiFeaturesConfigured && (
<FormField
control={form.control}
name="aiFeaturesEnabled"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>AI Features</Trans>
</FormLabel>
<FormControl>
<Select
{...field}
value={field.value === null ? '-1' : field.value.toString()}
onValueChange={(value) =>
field.onChange(value === 'true' ? true : value === 'false' ? false : null)
}
>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">
<Trans>Enabled</Trans>
</SelectItem>
<SelectItem value="false">
<Trans>Disabled</Trans>
</SelectItem>
{canInherit && (
<SelectItem value={'-1'}>
<Trans>Inherit from organisation</Trans>
</SelectItem>
)}
</SelectContent>
</Select>
</FormControl>
<FormDescription>
<Trans>
Enable AI-powered features such as automatic recipient detection. When
enabled, document content will be sent to AI providers. We only use providers
that do not retain data for training and prefer European regions where
available.
</Trans>
</FormDescription>
</FormItem>
)}
/>
)}
<div className="flex flex-row justify-end space-x-4">
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Update</Trans>
+1 -1
View File
@@ -201,7 +201,7 @@ export const SignInForm = ({
.otherwise(() => handleFallbackErrorMessages(error.code));
toast({
title: _(msg`Something went wrong`),
title: 'Something went wrong',
description: _(errorMessage),
duration: 10000,
variant: 'destructive',
@@ -1,78 +0,0 @@
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>
);
};
@@ -57,7 +57,7 @@ export type DocumentSigningCompleteDialogProps = {
name: string;
email: string;
};
directTemplatePayload?: {
recipientPayload?: {
name: string;
email: string;
};
@@ -89,7 +89,7 @@ export const DocumentSigningCompleteDialog = ({
recipient,
disabled = false,
allowDictateNextSigner = false,
directTemplatePayload,
recipientPayload,
defaultNextSigner,
buttonSize = 'lg',
position,
@@ -113,11 +113,11 @@ export const DocumentSigningCompleteDialog = ({
},
});
const directRecipientForm = useForm<TDirectRecipientFormSchema>({
const recipientForm = useForm<TDirectRecipientFormSchema>({
resolver: zodResolver(ZDirectRecipientFormSchema),
defaultValues: {
name: directTemplatePayload?.name ?? '',
email: directTemplatePayload?.email ?? '',
name: recipientPayload?.name ?? '',
email: recipientPayload?.email ?? '',
},
});
@@ -145,16 +145,16 @@ export const DocumentSigningCompleteDialog = ({
const onFormSubmit = async (data: TNextSignerFormSchema) => {
try {
let directRecipient: { name: string; email: string } | undefined;
let recipientOverridePayload: { name: string; email: string } | undefined;
if (directTemplatePayload && !directTemplatePayload.email) {
const isFormValid = await directRecipientForm.trigger();
if (recipientPayload && !recipientPayload.email) {
const isFormValid = await recipientForm.trigger();
if (!isFormValid) {
return;
}
directRecipient = directRecipientForm.getValues();
recipientOverridePayload = recipientForm.getValues();
}
// Check if 2FA is required
@@ -168,7 +168,7 @@ export const DocumentSigningCompleteDialog = ({
? { name: data.name, email: data.email }
: undefined;
await onSignatureComplete(nextSigner, data.accessAuthOptions, directRecipient);
await onSignatureComplete(nextSigner, data.accessAuthOptions, recipientOverridePayload);
} catch (error) {
const err = AppError.parseError(error);
@@ -222,7 +222,7 @@ export const DocumentSigningCompleteDialog = ({
<Trans>Are you sure?</Trans>
</DialogTitle>
<DialogDescription>
<div className="text-muted-foreground max-w-[50ch]">
<div className="max-w-[50ch] text-muted-foreground">
{match(recipient.role)
.with(RecipientRole.VIEWER, () => (
<span className="inline-flex flex-wrap">
@@ -250,19 +250,19 @@ export const DocumentSigningCompleteDialog = ({
</DialogDescription>
</DialogHeader>
<div className="border-border bg-muted/50 rounded-lg border p-4 text-center">
<p className="text-muted-foreground text-sm font-medium">{documentTitle}</p>
<div className="rounded-lg border border-border bg-muted/50 p-4 text-center">
<p className="text-sm font-medium text-muted-foreground">{documentTitle}</p>
</div>
{!showTwoFactorForm && (
<>
<fieldset disabled={form.formState.isSubmitting} className="border-none p-0">
{directTemplatePayload && !directTemplatePayload.email && (
<Form {...directRecipientForm}>
{recipientPayload && !recipientPayload.email && (
<Form {...recipientForm}>
<div className="mb-4 flex flex-col gap-4">
<div className="flex flex-col gap-4 md:flex-row">
<FormField
control={directRecipientForm.control}
control={recipientForm.control}
name="name"
render={({ field }) => (
<FormItem className="flex-1">
@@ -284,7 +284,7 @@ export const DocumentSigningCompleteDialog = ({
/>
<FormField
control={directRecipientForm.control}
control={recipientForm.control}
name="email"
render={({ field }) => (
<FormItem className="flex-1">
@@ -108,8 +108,8 @@ export const DocumentSigningForm = ({
await completeDocument({ nextSigner });
} catch (err) {
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while completing the document. Please try again.`),
title: 'Error',
description: 'An error occurred while completing the document. Please try again.',
variant: 'destructive',
});
@@ -187,74 +187,45 @@ export const DocumentSigningPageViewV1 = ({
<div className="mt-1.5 flex flex-wrap items-center justify-between gap-y-2 sm:mt-2.5 sm:gap-y-0">
<div className="max-w-[50ch]">
<span className="truncate text-muted-foreground" title={senderName}>
{senderName} {senderEmail}
</span>{' '}
<span className="text-muted-foreground">
{match(recipient.role)
.with(RecipientRole.VIEWER, () =>
includeSenderDetails ? (
<Trans>
<span className="truncate" title={senderName}>
{senderName} {senderEmail}
</span>{' '}
on behalf of "{document.team?.name}" has invited you to view this document
</Trans>
) : (
<Trans>
<span className="truncate" title={senderName}>
{senderName} {senderEmail}
</span>{' '}
has invited you to view this document
</Trans>
<Trans>has invited you to view this document</Trans>
),
)
.with(RecipientRole.SIGNER, () =>
includeSenderDetails ? (
<Trans>
<span className="truncate" title={senderName}>
{senderName} {senderEmail}
</span>{' '}
on behalf of "{document.team?.name}" has invited you to sign this document
</Trans>
) : (
<Trans>
<span className="truncate" title={senderName}>
{senderName} {senderEmail}
</span>{' '}
has invited you to sign this document
</Trans>
<Trans>has invited you to sign this document</Trans>
),
)
.with(RecipientRole.APPROVER, () =>
includeSenderDetails ? (
<Trans>
<span className="truncate" title={senderName}>
{senderName} {senderEmail}
</span>{' '}
on behalf of "{document.team?.name}" has invited you to approve this document
</Trans>
) : (
<Trans>
<span className="truncate" title={senderName}>
{senderName} {senderEmail}
</span>{' '}
has invited you to approve this document
</Trans>
<Trans>has invited you to approve this document</Trans>
),
)
.with(RecipientRole.ASSISTANT, () =>
includeSenderDetails ? (
<Trans>
<span className="truncate" title={senderName}>
{senderName} {senderEmail}
</span>{' '}
on behalf of "{document.team?.name}" has invited you to assist this document
</Trans>
) : (
<Trans>
<span className="truncate" title={senderName}>
{senderName} {senderEmail}
</span>{' '}
has invited you to assist this document
</Trans>
<Trans>has invited you to assist this document</Trans>
),
)
.otherwise(() => null)}
@@ -74,8 +74,8 @@ export function DocumentSigningRejectDialog({
});
toast({
title: t`Document rejected`,
description: t`The document has been successfully rejected.`,
title: 'Document rejected',
description: 'The document has been successfully rejected.',
duration: 5000,
});
@@ -88,8 +88,8 @@ export function DocumentSigningRejectDialog({
}
} catch (err) {
toast({
title: t`Error`,
description: t`An error occurred while rejecting the document. Please try again.`,
title: 'Error',
description: 'An error occurred while rejecting the document. Please try again.',
variant: 'destructive',
duration: 5000,
});
@@ -1,31 +1,28 @@
import { lazy, useEffect, useMemo, useState } from 'react';
import { lazy, useEffect, useMemo } 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 { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
import { FileTextIcon, SparklesIcon } from 'lucide-react';
import { Trans, useLingui } from '@lingui/react/macro';
import { FieldType, RecipientRole } from '@prisma/client';
import { FileTextIcon } from 'lucide-react';
import { Link, useSearchParams } from 'react-router';
import { isDeepEqual } from 'remeda';
import { match } from 'ts-pattern';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import type { NormalizedFieldWithContext } from '@documenso/lib/server-only/ai/envelope/detect-fields/types';
import {
FIELD_META_DEFAULT_VALUES,
type TCheckboxFieldMeta,
type TDateFieldMeta,
type TDropdownFieldMeta,
type TEmailFieldMeta,
type TFieldMetaSchema,
type TInitialsFieldMeta,
type TNameFieldMeta,
type TNumberFieldMeta,
type TRadioFieldMeta,
type TSignatureFieldMeta,
type TTextFieldMeta,
import type {
TCheckboxFieldMeta,
TDateFieldMeta,
TDropdownFieldMeta,
TEmailFieldMeta,
TFieldMetaSchema,
TInitialsFieldMeta,
TNameFieldMeta,
TNumberFieldMeta,
TRadioFieldMeta,
TSignatureFieldMeta,
TTextFieldMeta,
} from '@documenso/lib/types/field-meta';
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
@@ -34,7 +31,6 @@ import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/al
import { Button } from '@documenso/ui/primitives/button';
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 { EditorFieldDateForm } from '~/components/forms/editor/editor-field-date-form';
import { EditorFieldDropdownForm } from '~/components/forms/editor/editor-field-dropdown-form';
@@ -45,7 +41,6 @@ import { EditorFieldNumberForm } from '~/components/forms/editor/editor-field-nu
import { EditorFieldRadioForm } from '~/components/forms/editor/editor-field-radio-form';
import { EditorFieldSignatureForm } from '~/components/forms/editor/editor-field-signature-form';
import { EditorFieldTextForm } from '~/components/forms/editor/editor-field-text-form';
import { useCurrentTeam } from '~/providers/team';
import { EnvelopeEditorFieldDragDrop } from './envelope-editor-fields-drag-drop';
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
@@ -72,15 +67,11 @@ const FieldSettingsTypeTranslations: Record<FieldType, MessageDescriptor> = {
export const EnvelopeEditorFieldsPage = () => {
const [searchParams] = useSearchParams();
const team = useCurrentTeam();
const { envelope, editorFields, relativePath } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
const { _ } = useLingui();
const [isAiFieldDialogOpen, setIsAiFieldDialogOpen] = useState(false);
const { t } = useLingui();
const selectedField = useMemo(
() => structuredClone(editorFields.selectedField),
@@ -105,24 +96,6 @@ 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.
*/
@@ -229,35 +202,6 @@ export const EnvelopeEditorFieldsPage = () => {
selectedRecipientId={editorFields.selectedRecipient?.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>
{/* Field details section. */}
@@ -299,7 +243,7 @@ export const EnvelopeEditorFieldsPage = () => {
<div className="px-4 [&_label]:text-xs [&_label]:text-foreground/70">
<h3 className="text-sm font-semibold">
{_(FieldSettingsTypeTranslations[selectedField.type])}
{t(FieldSettingsTypeTranslations[selectedField.type])}
</h3>
{match(selectedField.type)
@@ -8,13 +8,11 @@ import {
type SensorAPI,
} from '@hello-pangea/dnd';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { DocumentSigningOrder, EnvelopeType, RecipientRole, SendStatus } from '@prisma/client';
import { motion } from 'framer-motion';
import { GripVerticalIcon, HelpCircleIcon, PlusIcon, SparklesIcon, TrashIcon } from 'lucide-react';
import { GripVerticalIcon, HelpCircleIcon, PlusIcon, TrashIcon } from 'lucide-react';
import { useFieldArray, useForm, useWatch } from 'react-hook-form';
import { useSearchParams } from 'react-router';
import { isDeepEqual, prop, sortBy } from 'remeda';
import { z } from 'zod';
@@ -23,11 +21,11 @@ import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounce
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import type { TDetectedRecipientSchema } from '@documenso/lib/server-only/ai/envelope/detect-recipients/schema';
import {
ZRecipientActionAuthTypesSchema,
ZRecipientAuthOptionsSchema,
} from '@documenso/lib/types/document-auth';
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
import { nanoid } from '@documenso/lib/universal/id';
import { canRecipientBeModified as utilCanRecipientBeModified } from '@documenso/lib/utils/recipients';
import { trpc } from '@documenso/trpc/react';
@@ -62,18 +60,12 @@ import { Input } from '@documenso/ui/primitives/input';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { AiRecipientDetectionDialog } from '~/components/dialogs/ai-recipient-detection-dialog';
import { useCurrentTeam } from '~/providers/team';
const ZEnvelopeRecipientsForm = z.object({
signers: z.array(
z.object({
formId: z.string().min(1),
id: z.number().optional(),
email: z
.string()
.email({ message: msg`Invalid email`.id })
.min(1),
email: ZRecipientEmailSchema,
name: z.string(),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(),
@@ -90,36 +82,14 @@ export const EnvelopeEditorRecipientForm = () => {
const { envelope, setRecipientsDebounced, updateEnvelope } = useCurrentEnvelopeEditor();
const organisation = useCurrentOrganisation();
const team = useCurrentTeam();
const { t } = useLingui();
const { toast } = useToast();
const { remaining } = useLimits();
const { user } = useSession();
const [searchParams, setSearchParams] = useSearchParams();
const [recipientSearchQuery, setRecipientSearchQuery] = useState('');
// AI recipient detection dialog state
const [isAiDialogOpen, setIsAiDialogOpen] = useState(() => searchParams.get('ai') === 'true');
const onAiDialogOpenChange = (open: boolean) => {
setIsAiDialogOpen(open);
if (!open && searchParams.get('ai') === 'true') {
setSearchParams(
(prev) => {
const newParams = new URLSearchParams(prev);
newParams.delete('ai');
return newParams;
},
{ replace: true },
);
}
};
const debouncedRecipientSearchQuery = useDebouncedValue(recipientSearchQuery, 500);
const initialId = useId();
@@ -228,12 +198,13 @@ export const EnvelopeEditorRecipientForm = () => {
keyName: 'nativeId',
});
const emptySigners = useCallback(
() => form.getValues('signers').filter((signer) => signer.email === ''),
[form],
const emptySignerIndex = watchedSigners.findIndex(
(signer) =>
!signer.name &&
!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(
(signer) => signer.email.toLowerCase() === user?.email?.toLowerCase(),
);
@@ -271,71 +242,6 @@ export const EnvelopeEditorRecipientForm = () => {
});
};
const onAiDetectionComplete = (detectedRecipients: TDetectedRecipientSchema[]) => {
const currentSigners = form.getValues('signers');
let nextSigningOrder =
currentSigners.length > 0
? Math.max(...currentSigners.map((s) => s.signingOrder ?? 0)) + 1
: 1;
// If the only signer is the default empty signer lets just replace it with the detected recipients
if (currentSigners.length === 1 && !currentSigners[0].name && !currentSigners[0].email) {
form.setValue(
'signers',
detectedRecipients.map((recipient, index) => ({
formId: nanoid(12),
name: recipient.name,
email: recipient.email,
role: recipient.role,
actionAuth: [],
signingOrder: index + 1,
})),
{
shouldValidate: true,
shouldDirty: true,
},
);
return;
}
for (const recipient of detectedRecipients) {
const emailExists = currentSigners.some(
(s) => s.email.toLowerCase() === recipient.email.toLowerCase(),
);
const nameExists = currentSigners.some(
(s) => s.name.toLowerCase() === recipient.name.toLowerCase(),
);
if ((emailExists && recipient.email) || (nameExists && recipient.name)) {
continue;
}
currentSigners.push({
formId: nanoid(12),
name: recipient.name,
email: recipient.email,
role: recipient.role,
actionAuth: [],
signingOrder: nextSigningOrder,
});
nextSigningOrder += 1;
}
form.setValue('signers', normalizeSigningOrders(currentSigners), {
shouldValidate: true,
shouldDirty: true,
});
toast({
title: t`Recipients added`,
description: t`${detectedRecipients.length} recipient(s) have been added from AI detection.`,
});
};
const onRemoveSigner = (index: number) => {
const signer = signers[index];
@@ -398,14 +304,8 @@ export const EnvelopeEditorRecipientForm = () => {
index: number,
suggestion: RecipientAutoCompleteOption,
) => {
setValue(`signers.${index}.email`, suggestion.email, {
shouldValidate: true,
shouldDirty: true,
});
setValue(`signers.${index}.name`, suggestion.name || '', {
shouldValidate: true,
shouldDirty: true,
});
setValue(`signers.${index}.email`, suggestion.email);
setValue(`signers.${index}.name`, suggestion.name || '');
};
const onDragEnd = useCallback(
@@ -558,21 +458,7 @@ export const EnvelopeEditorRecipientForm = () => {
return;
}
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,
});
const validatedFormValues = ZEnvelopeRecipientsForm.safeParse(formValues);
if (!validatedFormValues.success) {
return;
@@ -641,26 +527,6 @@ export const EnvelopeEditorRecipientForm = () => {
</div>
<div className="flex flex-row items-center space-x-2">
{team.preferences.aiFeaturesEnabled && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
type="button"
size="sm"
disabled={isSubmitting}
onClick={() => setIsAiDialogOpen(true)}
>
<SparklesIcon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<Trans>Detect recipients with AI</Trans>
</TooltipContent>
</Tooltip>
)}
<Button
variant="outline"
className="flex flex-row items-center"
@@ -736,9 +602,7 @@ export const EnvelopeEditorRecipientForm = () => {
});
}
}}
disabled={
isSubmitting || hasDocumentBeenSent || emptySigners().length !== 0
}
disabled={isSubmitting || hasDocumentBeenSent}
/>
</FormControl>
@@ -834,7 +698,7 @@ export const EnvelopeEditorRecipientForm = () => {
>
{signers.map((signer, index) => (
<Draggable
key={`${signer.nativeId}-${signer.signingOrder}`}
key={`${signer.id}-${signer.signingOrder}`}
draggableId={signer['nativeId']}
index={index}
isDragDisabled={
@@ -924,7 +788,7 @@ export const EnvelopeEditorRecipientForm = () => {
})}
>
{!showAdvancedSettings && index === 0 && (
<FormLabel required>
<FormLabel>
<Trans>Email</Trans>
</FormLabel>
)}
@@ -1110,14 +974,6 @@ export const EnvelopeEditorRecipientForm = () => {
onOpenChange={setShowSigningOrderConfirmation}
onConfirm={handleSigningOrderDisable}
/>
<AiRecipientDetectionDialog
open={isAiDialogOpen}
onOpenChange={onAiDialogOpenChange}
onComplete={onAiDetectionComplete}
envelopeId={envelope.id}
teamId={envelope.teamId}
/>
</CardContent>
</Card>
);
@@ -2,7 +2,7 @@ import { useMemo, useState } from 'react';
import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd';
import type { DropResult } from '@hello-pangea/dnd';
import { msg, plural } from '@lingui/core/macro';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { DocumentStatus } from '@prisma/client';
import { FileWarningIcon, GripVerticalIcon, Loader2 } from 'lucide-react';
@@ -226,12 +226,7 @@ export const EnvelopeEditorUploadPage = () => {
}
if (maximumEnvelopeItemCount <= localFiles.length) {
return msg({
message: plural(maximumEnvelopeItemCount, {
one: `You cannot upload more than # item per envelope.`,
other: `You cannot upload more than # items per envelope.`,
}),
});
return msg`You cannot upload more than ${maximumEnvelopeItemCount} items per envelope.`;
}
return null;
@@ -245,10 +240,7 @@ export const EnvelopeEditorUploadPage = () => {
if (maxItemsReached) {
toast({
title: plural(maximumEnvelopeItemCount, {
one: `You cannot upload more than # item per envelope.`,
other: `You cannot upload more than # items per envelope.`,
}),
title: t`You cannot upload more than ${maximumEnvelopeItemCount} items per envelope.`,
duration: 5000,
variant: 'destructive',
});
@@ -41,6 +41,11 @@ export const EnvelopeRecipientSelector = ({
}: EnvelopeRecipientSelectorProps) => {
const [showRecipientsSelector, setShowRecipientsSelector] = useState(false);
const getRecipientLabel = useCallback(
(recipient: Recipient) => extractRecipientLabel(recipient, recipients),
[recipients],
);
return (
<Popover open={showRecipientsSelector} onOpenChange={setShowRecipientsSelector}>
<PopoverTrigger asChild>
@@ -49,7 +54,7 @@ export const EnvelopeRecipientSelector = ({
variant="outline"
role="combobox"
className={cn(
'bg-background text-muted-foreground hover:text-foreground justify-between font-normal',
'justify-between bg-background font-normal text-muted-foreground hover:text-foreground',
getRecipientColorStyles(
Math.max(
recipients.findIndex((r) => r.id === selectedRecipient?.id),
@@ -59,16 +64,12 @@ export const EnvelopeRecipientSelector = ({
className,
)}
>
{selectedRecipient?.email && (
{selectedRecipient && (
<span className="flex-1 truncate text-left">
{selectedRecipient?.name} ({selectedRecipient?.email})
{getRecipientLabel(selectedRecipient)}
</span>
)}
{!selectedRecipient?.email && (
<span className="flex-1 truncate text-left">{selectedRecipient?.email}</span>
)}
<ChevronsUpDown className="ml-2 h-4 w-4" />
</Button>
</PopoverTrigger>
@@ -154,6 +155,11 @@ export const EnvelopeRecipientSelectorCommand = ({
[fields, recipients],
);
const getRecipientLabel = useCallback(
(recipient: Recipient) => extractRecipientLabel(recipient, recipients),
[recipients],
);
return (
<Command
value={selectedRecipient ? selectedRecipient.id.toString() : undefined}
@@ -162,21 +168,21 @@ export const EnvelopeRecipientSelectorCommand = ({
<CommandInput placeholder={placeholder} />
<CommandEmpty>
<span className="text-muted-foreground inline-block px-4">
<span className="inline-block px-4 text-muted-foreground">
<Trans>No recipient matching this description was found.</Trans>
</span>
</CommandEmpty>
{recipientsByRoleToDisplay().map(([role, roleRecipients], roleIndex) => (
<CommandGroup key={roleIndex}>
<div className="text-muted-foreground mb-1 ml-2 mt-2 text-xs font-medium">
<div className="mb-1 ml-2 mt-2 text-xs font-medium text-muted-foreground">
{t(RECIPIENT_ROLES_DESCRIPTION[role].roleNamePlural)}
</div>
{roleRecipients.length === 0 && (
<div
key={`${role}-empty`}
className="text-muted-foreground/80 px-4 pb-4 pt-2.5 text-center text-xs"
className="px-4 pb-4 pt-2.5 text-center text-xs text-muted-foreground/80"
>
<Trans>No recipients with this role</Trans>
</div>
@@ -205,18 +211,12 @@ export const EnvelopeRecipientSelectorCommand = ({
}}
>
<span
className={cn('text-foreground/70 truncate', {
className={cn('truncate text-foreground/70', {
'text-foreground/80': recipient.id === selectedRecipient?.id,
'opacity-50': isRecipientDisabled(recipient.id),
})}
>
{recipient.name && (
<span title={`${recipient.name} (${recipient.email})`}>
{recipient.name} ({recipient.email})
</span>
)}
{!recipient.name && <span title={recipient.email}>{recipient.email}</span>}
{getRecipientLabel(recipient)}
</span>
<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" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground max-w-xs">
<TooltipContent className="max-w-xs text-muted-foreground">
<Trans>
This document has already been sent to this recipient. You can no longer
edit this recipient.
@@ -250,3 +250,22 @@ export const EnvelopeRecipientSelectorCommand = ({
</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}`;
};
@@ -80,12 +80,14 @@ export const EnvelopeSignerCompleteDialog = () => {
const handleOnCompleteClick = async (
nextSigner?: { name: string; email: string },
accessAuthOptions?: TRecipientAccessAuth,
recipientDetails?: { name: string; email: string },
) => {
try {
await completeDocument({
token: recipient.token,
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
accessAuthOptions,
recipientOverride: recipientDetails,
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
});
@@ -205,9 +207,18 @@ export const EnvelopeSignerCompleteDialog = () => {
}
};
const directTemplatePayload = useMemo(() => {
const recipientPayload = useMemo(() => {
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 {
@@ -219,7 +230,7 @@ export const EnvelopeSignerCompleteDialog = () => {
return (
<DocumentSigningCompleteDialog
isSubmitting={isPending}
directTemplatePayload={directTemplatePayload}
recipientPayload={recipientPayload}
onSignatureComplete={
isDirectTemplate ? handleDirectTemplateCompleteClick : handleOnCompleteClick
}
@@ -1,6 +1,5 @@
import { type ReactNode, useState } from 'react';
import { plural } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
@@ -116,9 +115,7 @@ export const EnvelopeDropZoneWrapper = ({
? formatDocumentsPath(team.url)
: formatTemplatesPath(team.url);
const aiQueryParam = team.preferences.aiFeaturesEnabled ? '?ai=true' : '';
await navigate(`${pathPrefix}/${id}/edit${aiQueryParam}`);
await navigate(`${pathPrefix}/${id}/edit`);
} catch (err) {
const error = AppError.parseError(err);
@@ -156,10 +153,7 @@ export const EnvelopeDropZoneWrapper = ({
if (maxItemsReached) {
toast({
title: plural(maximumEnvelopeItemCount, {
one: `You cannot upload more than # item per envelope.`,
other: `You cannot upload more than # items per envelope.`,
}),
title: t`You cannot upload more than ${maximumEnvelopeItemCount} items per envelope.`,
duration: 5000,
variant: 'destructive',
});
@@ -226,9 +220,9 @@ export const EnvelopeDropZoneWrapper = ({
{children}
{isDragActive && (
<div className="fixed left-0 top-0 z-[9999] h-full w-full bg-muted/60 backdrop-blur-[4px]">
<div className="bg-muted/60 fixed left-0 top-0 z-[9999] h-full w-full backdrop-blur-[4px]">
<div className="pointer-events-none flex h-full w-full flex-col items-center justify-center">
<h2 className="text-2xl font-semibold text-foreground">
<h2 className="text-foreground text-2xl font-semibold">
{type === EnvelopeType.DOCUMENT ? (
<Trans>Upload Document</Trans>
) : (
@@ -236,7 +230,7 @@ export const EnvelopeDropZoneWrapper = ({
)}
</h2>
<p className="text-md mt-4 text-muted-foreground">
<p className="text-muted-foreground text-md mt-4">
<Trans>Drag and drop your PDF file here</Trans>
</p>
@@ -253,7 +247,7 @@ export const EnvelopeDropZoneWrapper = ({
team?.id === undefined &&
remaining.documents > 0 &&
Number.isFinite(remaining.documents) && (
<p className="mt-4 text-sm text-muted-foreground/80">
<p className="text-muted-foreground/80 mt-4 text-sm">
<Trans>
{remaining.documents} of {quota.documents} documents remaining this month.
</Trans>
@@ -264,10 +258,10 @@ export const EnvelopeDropZoneWrapper = ({
)}
{isLoading && (
<div className="absolute inset-0 z-50 bg-muted/30 backdrop-blur-[2px]">
<div className="bg-muted/30 absolute inset-0 z-50 backdrop-blur-[2px]">
<div className="pointer-events-none flex h-1/2 w-full flex-col items-center justify-center">
<Loader className="h-12 w-12 animate-spin text-primary" />
<p className="mt-8 font-medium text-foreground">
<Loader className="text-primary h-12 w-12 animate-spin" />
<p className="text-foreground mt-8 font-medium">
<Trans>Uploading</Trans>
</p>
</div>
@@ -1,6 +1,6 @@
import { useMemo, useState } from 'react';
import { msg, plural } from '@lingui/core/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
@@ -108,9 +108,7 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
? formatDocumentsPath(team.url)
: formatTemplatesPath(team.url);
const aiQueryParam = team.preferences.aiFeaturesEnabled ? '?ai=true' : '';
await navigate(`${pathPrefix}/${id}/edit${aiQueryParam}`);
await navigate(`${pathPrefix}/${id}/edit`);
toast({
title: type === EnvelopeType.DOCUMENT ? t`Document uploaded` : t`Template uploaded`,
@@ -155,10 +153,7 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
if (maxItemsReached) {
toast({
title: plural(maximumEnvelopeItemCount, {
one: `You cannot upload more than # item per envelope.`,
other: `You cannot upload more than # items per envelope.`,
}),
title: t`You cannot upload more than ${maximumEnvelopeItemCount} items per envelope.`,
duration: 5000,
variant: 'destructive',
});
-2
View File
@@ -10,8 +10,6 @@ import { HydratedRouter } from 'react-router/dom';
import { extractPostHogConfig } from '@documenso/lib/constants/feature-flags';
import { dynamicActivate } from '@documenso/lib/utils/i18n';
import './utils/polyfills/promise-with-resolvers';
function PosthogInit() {
const postHogConfig = extractPostHogConfig();
@@ -1,10 +1,8 @@
import { useLingui } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import { useLoaderData } from 'react-router';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { IS_AI_FEATURES_CONFIGURED } from '@documenso/lib/constants/app';
import { DocumentSignatureType } from '@documenso/lib/constants/document';
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
import { trpc } from '@documenso/trpc/react';
@@ -21,16 +19,9 @@ export function meta() {
return appMetaTags('Document Preferences');
}
export const loader = () => {
return {
isAiFeaturesConfigured: IS_AI_FEATURES_CONFIGURED(),
};
};
export default function OrganisationSettingsDocumentPage() {
const { isAiFeaturesConfigured } = useLoaderData<typeof loader>();
const { organisations } = useSession();
const organisation = useCurrentOrganisation();
const { t } = useLingui();
@@ -57,7 +48,6 @@ export default function OrganisationSettingsDocumentPage() {
includeSigningCertificate,
includeAuditLog,
signatureTypes,
aiFeaturesEnabled,
} = data;
if (
@@ -66,8 +56,7 @@ export default function OrganisationSettingsDocumentPage() {
documentDateFormat === null ||
includeSenderDetails === null ||
includeSigningCertificate === null ||
includeAuditLog === null ||
aiFeaturesEnabled === null
includeAuditLog === null
) {
throw new Error('Should not be possible.');
}
@@ -85,7 +74,6 @@ export default function OrganisationSettingsDocumentPage() {
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
aiFeaturesEnabled,
},
});
@@ -105,7 +93,7 @@ export default function OrganisationSettingsDocumentPage() {
if (isLoadingOrganisation || !organisationWithSettings) {
return (
<div className="flex items-center justify-center rounded-lg py-32">
<Loader className="h-6 w-6 animate-spin text-muted-foreground" />
<Loader className="text-muted-foreground h-6 w-6 animate-spin" />
</div>
);
}
@@ -122,7 +110,6 @@ export default function OrganisationSettingsDocumentPage() {
<section>
<DocumentPreferencesForm
canInherit={false}
isAiFeaturesConfigured={isAiFeaturesConfigured}
settings={organisationWithSettings.organisationGlobalSettings}
onFormSubmit={onDocumentPreferencesFormSubmit}
/>
@@ -1,5 +1,5 @@
import DocumentPage, { loader, meta } from '../../o.$orgUrl.settings.document';
import DocumentPage, { meta } from '../../o.$orgUrl.settings.document';
export { meta, loader };
export { meta };
export default DocumentPage;
@@ -1,8 +1,6 @@
import { useLingui } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import { useLoaderData } from 'react-router';
import { IS_AI_FEATURES_CONFIGURED } from '@documenso/lib/constants/app';
import { DocumentSignatureType } from '@documenso/lib/constants/document';
import { trpc } from '@documenso/trpc/react';
import { useToast } from '@documenso/ui/primitives/use-toast';
@@ -19,17 +17,7 @@ export function meta() {
return appMetaTags('Document Preferences');
}
export const loader = () => {
return {
isAiFeaturesConfigured: IS_AI_FEATURES_CONFIGURED(),
};
};
export default function TeamsSettingsPage() {
const { isAiFeaturesConfigured } = useLoaderData<typeof loader>();
console.log('isAiFeaturesConfigured', isAiFeaturesConfigured);
const team = useCurrentTeam();
const { t } = useLingui();
@@ -52,7 +40,6 @@ export default function TeamsSettingsPage() {
includeSigningCertificate,
includeAuditLog,
signatureTypes,
aiFeaturesEnabled,
} = data;
await updateTeamSettings({
@@ -65,7 +52,6 @@ export default function TeamsSettingsPage() {
includeSenderDetails,
includeSigningCertificate,
includeAuditLog,
aiFeaturesEnabled,
...(signatureTypes.length === 0
? {
typedSignatureEnabled: null,
@@ -96,7 +82,7 @@ export default function TeamsSettingsPage() {
if (isLoadingTeam || !teamWithSettings) {
return (
<div className="flex items-center justify-center rounded-lg py-32">
<Loader className="h-6 w-6 animate-spin text-muted-foreground" />
<Loader className="text-muted-foreground h-6 w-6 animate-spin" />
</div>
);
}
@@ -111,7 +97,6 @@ export default function TeamsSettingsPage() {
<section>
<DocumentPreferencesForm
canInherit={true}
isAiFeaturesConfigured={isAiFeaturesConfigured}
settings={teamWithSettings.teamSettings}
onFormSubmit={onDocumentPreferencesSubmit}
/>
@@ -1,7 +1,6 @@
import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { AlertTriangle, Building2, Database, Eye, Settings, UserCircle2 } from 'lucide-react';
import { data, isRouteErrorResponse } from 'react-router';
@@ -126,7 +125,6 @@ export async function loader({ params }: Route.LoaderArgs) {
export default function OrganisationSsoConfirmationTokenPage({ loaderData }: Route.ComponentProps) {
const { token, type, user, organisation } = loaderData;
const { _ } = useLingui();
const { toast } = useToast();
const navigate = useNavigate();
@@ -138,12 +136,12 @@ export default function OrganisationSsoConfirmationTokenPage({ loaderData }: Rou
await navigate('/');
toast({
title: _(msg`Account link declined`),
title: 'Account link declined',
});
},
onError: (error) => {
toast({
title: _(msg`Error declining account link`),
title: 'Error declining account link',
description: error.message,
});
},
@@ -155,12 +153,12 @@ export default function OrganisationSsoConfirmationTokenPage({ loaderData }: Rou
await navigate(formatOrganisationLoginPath(organisation.url));
toast({
title: _(msg`Account linked successfully`),
title: 'Account linked successfully',
});
},
onError: (error) => {
toast({
title: _(msg`Error linking account`),
title: 'Error linking account',
description: error.message,
});
},
@@ -1,30 +0,0 @@
/**
* Polyfill for Promise.withResolvers (ES2024)
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/withResolvers
*/
type PromiseWithResolvers<T> = {
promise: Promise<T>;
resolve: (value: T | PromiseLike<T>) => void;
reject: (reason?: unknown) => void;
};
// We're patching here
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const GlobalPromise = globalThis.Promise as any;
if (typeof GlobalPromise.withResolvers !== 'function') {
GlobalPromise.withResolvers = function <T>(): PromiseWithResolvers<T> {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
}
export {};
+1 -1
View File
@@ -108,5 +108,5 @@
"vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^5.1.4"
},
"version": "2.2.4"
"version": "2.1.0"
}
@@ -1,161 +0,0 @@
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();
}
};
-154
View File
@@ -1,154 +0,0 @@
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);
}
},
);
@@ -1,54 +0,0 @@
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>;
@@ -1,160 +0,0 @@
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();
}
};
@@ -1,152 +0,0 @@
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);
}
},
);
@@ -1,16 +0,0 @@
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>;
-9
View File
@@ -1,9 +0,0 @@
import { Hono } from 'hono';
import type { HonoEnv } from '../../router';
import { detectFieldsRoute } from './detect-fields';
import { detectRecipientsRoute } from './detect-recipients';
export const aiRoute = new Hono<HonoEnv>()
.route('/detect-recipients', detectRecipientsRoute)
.route('/detect-fields', detectFieldsRoute);
+1 -24
View File
@@ -12,11 +12,9 @@ import { API_V2_BETA_URL, API_V2_URL } from '@documenso/lib/constants/app';
import { jobsClient } from '@documenso/lib/jobs/client';
import { TelemetryClient } from '@documenso/lib/server-only/telemetry/telemetry-client';
import { getIpAddress } from '@documenso/lib/universal/get-ip-address';
import { env } from '@documenso/lib/utils/env';
import { logger } from '@documenso/lib/utils/logger';
import { openApiDocument } from '@documenso/trpc/server/open-api';
import { aiRoute } from './api/ai/route';
import { downloadRoute } from './api/download/download';
import { filesRoute } from './api/files/files';
import { type AppContext, appContext } from './context';
@@ -52,21 +50,6 @@ 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.
*/
@@ -102,10 +85,6 @@ app.route('/api/auth', auth);
// Files route.
app.route('/api/files', filesRoute);
// AI route.
app.use('/api/ai/*', aiRateLimitMiddleware);
app.route('/api/ai', aiRoute);
// API servers.
app.use(`/api/v1/*`, cors());
app.route('/api/v1', tsRestHonoApp);
@@ -136,8 +115,6 @@ app.use(`${API_V2_BETA_URL}/*`, async (c) =>
// Start telemetry client for anonymous usage tracking.
// Can be disabled by setting DOCUMENSO_DISABLE_TELEMETRY=true
if (env('NODE_ENV') !== 'development') {
void TelemetryClient.start();
}
void TelemetryClient.start();
export default app;
-3
View File
@@ -51,7 +51,6 @@ export default defineConfig({
ssr: {
noExternal: ['react-dropzone', 'plausible-tracker'],
external: [
'@napi-rs/canvas',
'@node-rs/bcrypt',
'@prisma/client',
'@documenso/tailwind-config',
@@ -65,7 +64,6 @@ export default defineConfig({
include: ['prop-types', 'file-selector', 'attr-accept'],
exclude: [
'node_modules',
'@napi-rs/canvas',
'@node-rs/bcrypt',
'@documenso/pdf-sign',
'sharp',
@@ -96,7 +94,6 @@ export default defineConfig({
build: {
rollupOptions: {
external: [
'@napi-rs/canvas',
'@node-rs/bcrypt',
'@documenso/pdf-sign',
'@aws-sdk/cloudfront-signer',
-3
View File
@@ -70,7 +70,6 @@ COPY --from=builder /app/out/json/ .
COPY --from=builder /app/out/package-lock.json ./package-lock.json
COPY --from=builder /app/lingui.config.ts ./lingui.config.ts
COPY --from=builder /app/patches ./patches
RUN npm ci
@@ -109,8 +108,6 @@ WORKDIR /app
COPY --from=builder --chown=nodejs:nodejs /app/out/json/ .
# Copy the tailwind config files across
COPY --from=builder --chown=nodejs:nodejs /app/out/full/packages/tailwind-config ./packages/tailwind-config
# Copy the patches across
COPY --from=builder --chown=nodejs:nodejs /app/patches ./patches
RUN npm ci --only=production
+231 -651
View File
File diff suppressed because it is too large Load Diff
+3 -7
View File
@@ -5,9 +5,8 @@
"apps/*",
"packages/*"
],
"version": "2.2.4",
"version": "2.1.0",
"scripts": {
"postinstall": "patch-package",
"build": "turbo run build",
"dev": "turbo run dev --filter=@documenso/remix",
"dev:remix": "turbo run dev --filter=@documenso/remix",
@@ -64,7 +63,7 @@
"lint-staged": "^16.2.7",
"nanoid": "^5.1.6",
"nodemailer": "^7.0.10",
"pdfjs-dist": "5.4.449",
"pdfjs-dist": "5.4.296",
"pino": "^9.14.0",
"pino-pretty": "^13.1.2",
"playwright": "1.56.1",
@@ -84,22 +83,19 @@
"zod-prisma-types": "3.3.5"
},
"dependencies": {
"@ai-sdk/google-vertex": "3.0.81",
"@documenso/pdf-sign": "^0.1.0",
"@documenso/prisma": "*",
"@lingui/conf": "^5.6.0",
"@lingui/core": "^5.6.0",
"ai": "^5.0.104",
"inngest-cli": "^1.13.7",
"luxon": "^3.7.2",
"patch-package": "^8.0.1",
"posthog-node": "4.18.0",
"react": "^18",
"typescript": "5.6.2",
"zod": "^3.25.76"
},
"overrides": {
"pdfjs-dist": "5.4.449",
"pdfjs-dist": "5.4.296",
"typescript": "5.6.2",
"zod": "^3.25.76"
}
@@ -6,6 +6,7 @@ import { pick } from 'remeda';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
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 {
DocumentDistributionMethod,
@@ -23,7 +24,9 @@ import type {
TCreateEnvelopePayload,
TCreateEnvelopeResponse,
} 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 { 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 { TUpdateEnvelopeRequest } from '@documenso/trpc/server/envelope-router/update-envelope.types';
@@ -557,4 +560,543 @@ test.describe('API V2 Envelopes', () => {
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');
});
});
});
+1 -1
View File
@@ -14,7 +14,7 @@
"devDependencies": {
"@documenso/lib": "*",
"@documenso/prisma": "*",
"@napi-rs/canvas": "^0.1.83",
"@napi-rs/canvas": "^0.1.82",
"@playwright/test": "1.56.1",
"@types/node": "^20",
"@types/pngjs": "^6.0.5",
-3
View File
@@ -18,6 +18,3 @@ export const SUPPORT_EMAIL = env('NEXT_PUBLIC_SUPPORT_EMAIL') ?? 'support@docume
export const USE_INTERNAL_URL_BROWSERLESS = () =>
env('NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS') === 'true';
export const IS_AI_FEATURES_CONFIGURED = () =>
!!env('GOOGLE_VERTEX_PROJECT_ID') && !!env('GOOGLE_VERTEX_API_KEY');
@@ -5,6 +5,7 @@ import { EnvelopeType, ReadStatus, SendStatus, SigningStatus } from '@prisma/cli
import { mailer } from '@documenso/email/mailer';
import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
import { isRecipientEmailValidForSending } from '@documenso/lib/utils/recipients';
import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
@@ -77,7 +78,8 @@ export const run = async ({
const recipientsToNotify = envelope.recipients.filter(
(recipient) =>
(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 () => {
@@ -12,6 +12,7 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { getEmailContext } from '../../../server-only/email/get-email-context';
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
import { unsafeBuildEnvelopeIdQuery } from '../../../utils/envelope';
import { isRecipientEmailValidForSending } from '../../../utils/recipients';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSendRecipientSignedEmailJobDefinition } from './send-recipient-signed-email';
@@ -79,8 +80,8 @@ export const run = async ({
const recipientReference = recipientName || recipientEmail;
// Don't send notification if the owner is the one who signed
if (owner.email === recipientEmail) {
// Don't send notification if the owner is the one who signed.
if (owner.email === recipientEmail || !isRecipientEmailValidForSending(recipient)) {
return;
}
@@ -6,6 +6,7 @@ import { EnvelopeType, SendStatus, SigningStatus } from '@prisma/client';
import { mailer } from '@documenso/email/mailer';
import DocumentRejectedEmail from '@documenso/email/templates/document-rejected';
import DocumentRejectionConfirmedEmail from '@documenso/email/templates/document-rejection-confirmed';
import { isRecipientEmailValidForSending } from '@documenso/lib/utils/recipients';
import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
@@ -85,36 +86,38 @@ export const run = async ({
const i18n = await getI18nInstance(emailLanguage);
// Send confirmation email to the recipient who rejected
await io.runTask('send-rejection-confirmation-email', async () => {
const recipientTemplate = createElement(DocumentRejectionConfirmedEmail, {
recipientName: recipient.name,
documentName: envelope.title,
documentOwnerName: envelope.user.name || envelope.user.email,
reason: recipient.rejectionReason || '',
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
});
if (isRecipientEmailValidForSending(recipient)) {
await io.runTask('send-rejection-confirmation-email', async () => {
const recipientTemplate = createElement(DocumentRejectionConfirmedEmail, {
recipientName: recipient.name,
documentName: envelope.title,
documentOwnerName: envelope.user.name || envelope.user.email,
reason: recipient.rejectionReason || '',
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
});
const [html, text] = await Promise.all([
renderEmailWithI18N(recipientTemplate, { lang: emailLanguage, branding }),
renderEmailWithI18N(recipientTemplate, {
lang: emailLanguage,
branding,
plainText: true,
}),
]);
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,
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
await io.runTask('send-owner-notification-email', async () => {
@@ -12,6 +12,7 @@ import {
import { mailer } from '@documenso/email/mailer';
import DocumentInviteEmailTemplate from '@documenso/email/templates/document-invite';
import { isRecipientEmailValidForSending } from '@documenso/lib/utils/recipients';
import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
@@ -177,31 +178,33 @@ export const run = async ({
includeSenderDetails: settings.includeSenderDetails,
});
await io.runTask('send-signing-email', async () => {
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, {
lang: emailLanguage,
branding,
plainText: true,
}),
]);
if (isRecipientEmailValidForSending(recipient)) {
await io.runTask('send-signing-email', async () => {
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, {
lang: emailLanguage,
branding,
plainText: true,
}),
]);
await mailer.sendMail({
to: {
name: recipient.name,
address: recipient.email,
},
from: senderEmail,
replyTo: replyToEmail,
subject: renderCustomEmailTemplate(
documentMeta?.subject || emailSubject,
customEmailTemplate,
),
html,
text,
await mailer.sendMail({
to: {
name: recipient.name,
address: recipient.email,
},
from: senderEmail,
replyTo: replyToEmail,
subject: renderCustomEmailTemplate(
documentMeta?.subject || emailSubject,
customEmailTemplate,
),
html,
text,
});
});
});
}
await io.runTask('update-recipient', async () => {
await prisma.recipient.update({
+1 -5
View File
@@ -15,7 +15,6 @@
"clean": "rimraf node_modules"
},
"dependencies": {
"@ai-sdk/google-vertex": "3.0.81",
"@aws-sdk/client-s3": "^3.936.0",
"@aws-sdk/client-sesv2": "^3.936.0",
"@aws-sdk/cloudfront-signer": "^3.935.0",
@@ -28,7 +27,6 @@
"@lingui/core": "^5.6.0",
"@lingui/macro": "^5.6.0",
"@lingui/react": "^5.6.0",
"@napi-rs/canvas": "^0.1.83",
"@noble/ciphers": "0.6.0",
"@noble/hashes": "1.8.0",
"@node-rs/bcrypt": "^1.10.7",
@@ -37,7 +35,6 @@
"@sindresorhus/slugify": "^3.0.0",
"@team-plain/typescript-sdk": "^5.11.0",
"@vvo/tzdb": "^6.196.0",
"ai": "^5.0.104",
"csv-parse": "^6.1.0",
"inngest": "^3.45.1",
"jose": "^6.1.2",
@@ -46,7 +43,6 @@
"luxon": "^3.7.2",
"nanoid": "^5.1.6",
"oslo": "^0.17.0",
"p-map": "^7.0.4",
"pg": "^8.16.3",
"pino": "^9.14.0",
"pino-pretty": "^13.1.2",
@@ -67,4 +63,4 @@
"@types/luxon": "^3.7.1",
"@types/pg": "^8.15.6"
}
}
}
@@ -5,6 +5,7 @@ import { EnvelopeType } from '@prisma/client';
import { mailer } from '@documenso/email/mailer';
import { AccessAuth2FAEmailTemplate } from '@documenso/email/templates/access-auth-2fa';
import { isRecipientEmailValidForSending } from '@documenso/lib/utils/recipients';
import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
@@ -69,6 +70,12 @@ 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({
envelopeId,
email: recipient.email,
@@ -14,6 +14,7 @@ import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { isRecipientEmailValidForSending } from '../../utils/recipients';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { getEmailContext } from '../email/get-email-context';
@@ -64,14 +65,18 @@ export const adminSuperDeleteDocument = async ({
envelope.documentMeta,
).documentDeleted;
const recipientsToNotify = envelope.recipients.filter((recipient) =>
isRecipientEmailValidForSending(recipient),
);
// if the document is pending, send cancellation emails to all recipients
if (
status === DocumentStatus.PENDING &&
envelope.recipients.length > 0 &&
recipientsToNotify.length > 0 &&
isDocumentDeletedEmailEnabled
) {
await Promise.all(
envelope.recipients.map(async (recipient) => {
recipientsToNotify.map(async (recipient) => {
if (recipient.sendStatus !== SendStatus.SENT) {
return;
}
@@ -1,91 +0,0 @@
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,
};
};
@@ -1,323 +0,0 @@
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 ?? [];
};
@@ -1,69 +0,0 @@
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`;
@@ -1,54 +0,0 @@
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>;
@@ -1,30 +0,0 @@
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;
};
@@ -1,237 +0,0 @@
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;
};
@@ -1,41 +0,0 @@
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.`;
@@ -1,24 +0,0 @@
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>;
-9
View File
@@ -1,9 +0,0 @@
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'),
});
-1
View File
@@ -1 +0,0 @@
export * from 'ai';
@@ -1,85 +0,0 @@
import pMap from 'p-map';
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs';
import { Canvas, Image, Path2D } from 'skia-canvas';
// @ts-expect-error napi-rs/canvas satisfies the requirements
globalThis.Path2D = Path2D;
// @ts-expect-error napi-rs/canvas satisfies the requirements
globalThis.Image = Image;
class SkiaCanvasFactory {
_createCanvas(width: number, height: number) {
return new Canvas(width, height);
}
create(width: number, height: number) {
const canvas = this._createCanvas(width, height);
return {
canvas,
context: canvas.getContext('2d'),
};
}
reset(canvasAndContext: { canvas: Canvas }, width: number, height: number) {
canvasAndContext.canvas.width = width;
canvasAndContext.canvas.height = height;
}
destroy(canvasAndContext: { canvas: Canvas | null; context: unknown }) {
if (canvasAndContext.canvas) {
canvasAndContext.canvas.width = 0;
canvasAndContext.canvas.height = 0;
}
canvasAndContext.canvas = null;
canvasAndContext.context = null;
}
}
export type PdfToImagesOptions = {
scale?: number;
};
export const pdfToImages = async (pdfBytes: Uint8Array, options: PdfToImagesOptions = {}) => {
const { scale = 2 } = options;
const pdf = await pdfjsLib.getDocument({
data: pdfBytes,
CanvasFactory: SkiaCanvasFactory,
}).promise;
const images = await pMap(
Array.from({ length: pdf.numPages }),
async (_, index) => {
const pageNumber = index + 1;
const page = await pdf.getPage(pageNumber);
const viewport = page.getViewport({ scale });
const canvas = new Canvas(viewport.width, viewport.height);
const canvasContext = canvas.getContext('2d');
await page.render({
// @ts-expect-error napi-rs/canvas satifies the requirements
canvas,
// @ts-expect-error napi-rs/canvas satifies the requirements
canvasContext,
viewport,
}).promise;
return {
pageNumber,
image: await canvas.toBuffer('jpeg'),
width: Math.floor(viewport.width),
height: Math.floor(viewport.height),
mimeType: 'image/jpeg',
};
},
{ concurrency: 10 },
);
void pdf.destroy();
return images;
};
@@ -2,6 +2,7 @@ import {
DocumentSigningOrder,
DocumentStatus,
EnvelopeType,
FieldType,
RecipientRole,
SendStatus,
SigningStatus,
@@ -43,6 +44,14 @@ export type CompleteDocumentWithTokenOptions = {
email: 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 ({
@@ -52,6 +61,7 @@ export const completeDocumentWithToken = async ({
accessAuthOptions,
requestMetadata,
nextSigner,
recipientOverride,
}: CompleteDocumentWithTokenOptions) => {
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
@@ -116,6 +126,35 @@ export const completeDocumentWithToken = async ({
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
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
documentAuth: envelope.authOptions,
@@ -129,6 +168,12 @@ 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({
type: 'ACCESS_2FA',
documentAuthOptions: envelope.authOptions,
@@ -176,9 +221,43 @@ export const completeDocumentWithToken = async ({
data: {
signingStatus: SigningStatus.SIGNED,
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({
documentAuth: envelope.authOptions,
recipientAuth: recipient.authOptions,
@@ -189,13 +268,13 @@ export const completeDocumentWithToken = async ({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
envelopeId: envelope.id,
user: {
name: recipient.name,
email: recipient.email,
name: recipientName,
email: recipientEmail,
},
requestMetadata,
data: {
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientEmail: recipientEmail,
recipientName: recipientName,
recipientId: recipient.id,
recipientRole: recipient.role,
actionAuth: authOptions.derivedRecipientActionAuth,
@@ -204,13 +283,15 @@ export const completeDocumentWithToken = async ({
});
});
await jobs.triggerJob({
name: 'send.recipient.signed.email',
payload: {
documentId: legacyDocumentId,
recipientId: recipient.id,
},
});
if (recipientEmail) {
await jobs.triggerJob({
name: 'send.recipient.signed.email',
payload: {
documentId: legacyDocumentId,
recipientId: recipient.id,
},
});
}
const pendingRecipients = await prisma.recipient.findMany({
select: {
@@ -247,8 +328,8 @@ export const completeDocumentWithToken = async ({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
envelopeId: envelope.id,
user: {
name: recipient.name,
email: recipient.email,
name: recipientName,
email: recipientEmail,
},
requestMetadata,
data: {
@@ -21,6 +21,7 @@ import type { ApiRequestMetadata } from '../../universal/extract-request-metadat
import { isDocumentCompleted } from '../../utils/document';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { type EnvelopeIdOptions, unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
import { isRecipientEmailValidForSending } from '../../utils/recipients';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { getEmailContext } from '../email/get-email-context';
import { getMemberRoles } from '../team/get-member-roles';
@@ -209,7 +210,7 @@ const handleDocumentOwnerDelete = async ({
// Send cancellation emails to recipients.
await Promise.all(
envelope.recipients.map(async (recipient) => {
if (recipient.sendStatus !== SendStatus.SENT) {
if (recipient.sendStatus !== SendStatus.SENT || !isRecipientEmailValidForSending(recipient)) {
return;
}
@@ -26,6 +26,7 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { isDocumentCompleted } from '../../utils/document';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { isRecipientEmailValidForSending } from '../../utils/recipients';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { getEmailContext } from '../email/get-email-context';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
@@ -118,7 +119,7 @@ export const resendDocument = async ({
await Promise.all(
recipientsToRemind.map(async (recipient) => {
if (recipient.role === RecipientRole.CC) {
if (recipient.role === RecipientRole.CC || !isRecipientEmailValidForSending(recipient)) {
return;
}
@@ -16,6 +16,7 @@ import { getFileServerSide } from '../../universal/upload/get-file.server';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
import { isRecipientEmailValidForSending } from '../../utils/recipients';
import { renderCustomEmailTemplate } from '../../utils/render-custom-email-template';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { formatDocumentsPath } from '../../utils/teams';
@@ -176,8 +177,12 @@ export const sendCompletedEmail = async ({ id, requestMetadata }: SendDocumentOp
return;
}
const recipientsToNotify = envelope.recipients.filter((recipient) =>
isRecipientEmailValidForSending(recipient),
);
await Promise.all(
envelope.recipients.map(async (recipient) => {
recipientsToNotify.map(async (recipient) => {
const customEmailTemplate = {
'signer.name': recipient.name,
'signer.email': recipient.email,
@@ -35,8 +35,10 @@ import {
import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
import { isDocumentCompleted } from '../../utils/document';
import { extractDocumentAuthMethods } from '../../utils/document-auth';
import { type EnvelopeIdOptions, mapSecondaryIdToDocumentId } from '../../utils/envelope';
import { toCheckboxCustomText, toRadioCustomText } from '../../utils/fields';
import { isRecipientEmailValidForSending } from '../../utils/recipients';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
@@ -128,6 +130,24 @@ 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
// decide if we want to enforce this for API & templates.
// const fields = await getFieldsForDocument({
@@ -12,6 +12,7 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
import { isRecipientEmailValidForSending } from '../../utils/recipients';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { getEmailContext } from '../email/get-email-context';
@@ -69,6 +70,11 @@ export const sendPendingEmail = async ({ id, recipientId }: SendPendingEmailOpti
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 template = createElement(DocumentPendingEmailTemplate, {
@@ -27,7 +27,7 @@ export interface CreateEnvelopeRecipientsOptions {
accessAuth?: TRecipientAccessAuthTypes[];
actionAuth?: TRecipientActionAuthTypes[];
}[];
requestMetadata?: ApiRequestMetadata;
requestMetadata: ApiRequestMetadata;
}
export const createEnvelopeRecipients = async ({
@@ -14,7 +14,7 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { canRecipientBeModified } from '../../utils/recipients';
import { canRecipientBeModified, isRecipientEmailValidForSending } from '../../utils/recipients';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getEmailContext } from '../email/get-email-context';
@@ -142,7 +142,8 @@ export const deleteEnvelopeRecipient = async ({
if (
recipientToDelete.sendStatus === SendStatus.SENT &&
isRecipientRemovedEmailEnabled &&
envelope.type === EnvelopeType.DOCUMENT
envelope.type === EnvelopeType.DOCUMENT &&
isRecipientEmailValidForSending(recipientToDelete)
) {
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 { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { type EnvelopeIdOptions, mapSecondaryIdToDocumentId } from '../../utils/envelope';
import { canRecipientBeModified } from '../../utils/recipients';
import { canRecipientBeModified, isRecipientEmailValidForSending } from '../../utils/recipients';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { getEmailContext } from '../email/get-email-context';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
@@ -294,10 +294,14 @@ export const setDocumentRecipients = async ({
envelope.documentMeta,
).recipientRemoved;
// Send emails to deleted recipients.
// Send emails to deleted recipients who have emails.
await Promise.all(
removedRecipients.map(async (recipient) => {
if (recipient.sendStatus !== SendStatus.SENT || !isRecipientRemovedEmailEnabled) {
if (
recipient.sendStatus !== SendStatus.SENT ||
!isRecipientRemovedEmailEnabled ||
!isRecipientEmailValidForSending(recipient)
) {
return;
}
-4
View File
@@ -2817,10 +2817,6 @@ msgstr "Erstellt am {0}"
msgid "CSV Structure"
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
msgid "Current"
msgstr "Aktuell"
-4
View File
@@ -2817,10 +2817,6 @@ msgstr "Creado el {0}"
msgid "CSV Structure"
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
msgid "Current"
msgstr "Actual"
-4
View File
@@ -2817,10 +2817,6 @@ msgstr "Créé le {0}"
msgid "CSV Structure"
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
msgid "Current"
msgstr "Actuel"
-4
View File
@@ -2817,10 +2817,6 @@ msgstr "Creato il {0}"
msgid "CSV Structure"
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
msgid "Current"
msgstr "Corrente"
-4
View File
@@ -2817,10 +2817,6 @@ msgstr "作成日 {0}"
msgid "CSV Structure"
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
msgid "Current"
msgstr "現在"
-4
View File
@@ -2817,10 +2817,6 @@ msgstr "{0}에 생성됨"
msgid "CSV Structure"
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
msgid "Current"
msgstr "현재"
-4
View File
@@ -2817,10 +2817,6 @@ msgstr "Aangemaakt op {0}"
msgid "CSV Structure"
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
msgid "Current"
msgstr "Huidig"
+21 -25
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: pl\n"
"Project-Id-Version: documenso-app\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2025-11-27 18:05\n"
"PO-Revision-Date: 2025-11-27 05:32\n"
"Last-Translator: \n"
"Language-Team: Polish\n"
"Plural-Forms: nplurals=4; plural=(n==1 ? 0 : (n%10>=2 && n%10<=4) && (n%100<12 || n%100>14) ? 1 : n!=1 && (n%10>=0 && n%10<=1) || (n%10>=5 && n%10<=9) || (n%100>=12 && n%100<=14) ? 2 : 3);\n"
@@ -944,7 +944,7 @@ msgstr "Akcje"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.members.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
msgid "Active"
msgstr "Aktywne"
msgstr "Aktywny"
#: apps/remix/app/routes/_authenticated+/settings+/security._index.tsx
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
@@ -1239,7 +1239,7 @@ msgstr "Wszystkie wstawione podpisy zostaną unieważnione"
#: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx
msgid "All recipients have signed. The document is being processed and you will receive an email copy shortly."
msgstr "Wszyscy odbiorcy podpisali dokument. Dokument jest przetwarzany i wkrótce otrzymasz jego kopię."
msgstr "Wszyscy odbiorcy podpisali. Dokument jest przetwarzany i wkrótce otrzymasz jego kopię emailem."
#: apps/remix/app/components/dialogs/document-delete-dialog.tsx
msgid "All recipients will be notified"
@@ -2817,10 +2817,6 @@ msgstr "Utworzono {0}"
msgid "CSV Structure"
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
msgid "Current"
msgstr "Obecna"
@@ -3102,7 +3098,7 @@ msgstr "Szczegóły"
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
msgid "Developer Mode"
msgstr "Tryb programisty"
msgstr "Tryb deweloperski"
#: apps/remix/app/components/tables/settings-security-activity-table.tsx
#: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx
@@ -3716,7 +3712,7 @@ msgstr "Przeciągnij i upuść plik PDF"
#: packages/ui/primitives/signature-pad/signature-pad.tsx
msgctxt "Draw signature"
msgid "Draw"
msgstr "Rysowany"
msgstr "Rysuj"
#: apps/remix/app/components/embed/authoring/configure-document-upload.tsx
msgid "Drop your document here"
@@ -4256,7 +4252,7 @@ msgstr "Wszyscy podpisali"
#: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx
msgid "Everyone has signed! You will receive an email copy of the signed document."
msgstr "Wszyscy podpisali! Otrzymasz wiadomość z podpisanym dokumentem."
msgstr "Wszyscy podpisali! Otrzymasz kopię podpisanego dokumentu emailem."
#: apps/remix/app/components/dialogs/passkey-create-dialog.tsx
msgid "Exceeded timeout"
@@ -4837,7 +4833,7 @@ msgstr "Uwaga: Co to oznacza"
#: apps/remix/app/components/tables/user-billing-organisations-table.tsx
msgid "Inactive"
msgstr "Nieaktywna"
msgstr "Nieaktywne"
#: apps/remix/app/components/general/app-nav-mobile.tsx
#: apps/remix/app/components/general/app-nav-mobile.tsx
@@ -5208,7 +5204,7 @@ msgstr "Wygenerowane linki"
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
msgid "Listening to"
msgstr "Nasłuchiwanie"
msgstr "Nasłuchuje"
#: apps/remix/app/components/general/document/document-page-view-recent-activity.tsx
msgid "Load older activity"
@@ -5252,7 +5248,7 @@ msgstr "Zaloguj się"
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
msgid "Logs"
msgstr "Logi"
msgstr "Dzienniki"
#: apps/remix/app/components/tables/admin-organisations-table.tsx
#: apps/remix/app/components/tables/organisation-teams-table.tsx
@@ -5292,7 +5288,7 @@ msgstr "Zarządzaj płatnościami"
#: apps/remix/app/routes/_authenticated+/settings+/billing.tsx
msgid "Manage billing and subscriptions for organisations where you have billing management permissions."
msgstr "Zarządzaj płatnościami i subskrypcjami organizacji, w których masz uprawnienia do zarządzania płatnościami."
msgstr "Zarządzaj rozliczeniami i subskrypcjami dla organizacji, w których masz uprawnienia do zarządzania rozliczeniami."
#: apps/remix/app/components/dialogs/public-profile-template-manage-dialog.tsx
msgid "Manage details for this public template"
@@ -6192,7 +6188,7 @@ msgstr "Hasło zostało zaktualizowane!"
#: apps/remix/app/components/tables/user-billing-organisations-table.tsx
msgid "Past Due"
msgstr "Przeterminowana"
msgstr "Po terminie"
#: apps/remix/app/components/general/organisations/organisation-billing-banner.tsx
#: apps/remix/app/components/general/organisations/organisation-billing-banner.tsx
@@ -6412,7 +6408,7 @@ msgstr "Otwórz aplikację uwierzytelniającą i wpisz 6-cyfrowy kod dla tego do
#: apps/remix/app/components/general/document-signing/document-signing-reject-dialog.tsx
msgid "Please provide a reason for rejecting this document"
msgstr "Podaj powód odrzucenia dokumentu"
msgstr "Podaj powód odrzucenia tego dokumentu"
#: apps/remix/app/components/forms/2fa/disable-authenticator-app-dialog.tsx
msgid "Please provide a token from the authenticator, or a backup code. If you do not have a backup code available, please contact support."
@@ -7134,7 +7130,7 @@ msgstr "Szukaj tytułu dokumentu"
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks.$id._index.tsx
msgid "Search by ID"
msgstr "Szukaj identyfikatora"
msgstr "Szukaj po ID"
#: apps/remix/app/components/tables/admin-dashboard-users-table.tsx
msgid "Search by name or email"
@@ -7231,7 +7227,7 @@ msgstr "Wybierz metody dostępu"
#: apps/remix/app/components/dialogs/webhook-test-dialog.tsx
msgid "Select an event type"
msgstr "Wybierz rodzaj zdarzenia"
msgstr "Wybierz typ zdarzenia"
#: apps/remix/app/components/dialogs/sign-field-dropdown-dialog.tsx
#: packages/ui/primitives/combobox.tsx
@@ -8380,7 +8376,7 @@ msgstr "Szablony umożliwiają szybkie generowanie dokumentów z wcześniej wype
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks.$id._index.tsx
msgid "Test"
msgstr "Testuj"
msgstr "Test"
#: apps/remix/app/components/dialogs/webhook-test-dialog.tsx
msgid "Test Webhook"
@@ -9257,13 +9253,13 @@ msgstr "Weryfikacja dwuetapowa"
#: apps/remix/app/components/tables/admin-document-recipient-item-table.tsx
#: apps/remix/app/components/tables/templates-table.tsx
msgid "Type"
msgstr "Rodzaj"
msgstr "Typ"
#: packages/lib/constants/document.ts
#: packages/ui/primitives/signature-pad/signature-pad.tsx
msgctxt "Type signature"
msgid "Type"
msgstr "Pisany"
msgstr "Wpisz"
#: apps/remix/app/components/general/app-command-menu.tsx
msgid "Type a command or search..."
@@ -9560,7 +9556,7 @@ msgstr "Przesłany"
#: packages/ui/primitives/signature-pad/signature-pad.tsx
msgctxt "Upload signature"
msgid "Upload"
msgstr "Przesłany"
msgstr "Prześlij"
#: apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx
msgid "Upload a CSV file to create multiple documents from this template. Each row represents one document with its recipient details."
@@ -10350,7 +10346,7 @@ msgstr "Webhook nie został znaleziony"
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
msgid "Webhook successfully sent"
msgstr "Webhook został wysłany"
msgstr "Webhook został pomyślnie wysłany"
#: apps/remix/app/components/dialogs/webhook-edit-dialog.tsx
msgid "Webhook updated"
@@ -10712,7 +10708,7 @@ msgstr "Nie masz uprawnień do utworzenia tokena dla tego zespołu"
#: apps/remix/app/components/tables/user-billing-organisations-table.tsx
msgid "You don't manage billing for any organisations."
msgstr "Nie zarządzasz płatnościami żadnej organizacji."
msgstr "Nie zarządzasz rozliczeniami dla żadnej organizacji."
#: packages/email/template-components/template-document-cancel.tsx
msgid "You don't need to sign it anymore."
@@ -10931,7 +10927,7 @@ msgstr "Kod z aplikacji uwierzytelniającej będzie wymagany podczas logowania."
#: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx
msgid "You will receive an email copy of the signed document once everyone has signed."
msgstr "Otrzymasz kopię dokumentu, gdy wszyscy go podpiszą."
msgstr "Otrzymasz kopię podpisanego dokumentu emailem, gdy wszyscy go podpiszą."
#: apps/remix/app/routes/_unauthenticated+/organisation.sso.confirmation.$token.tsx
msgid "Your Account"
-4
View File
@@ -2817,10 +2817,6 @@ msgstr "创建于 {0}"
msgid "CSV Structure"
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
msgid "Current"
msgstr "当前"
+11
View File
@@ -1,3 +1,4 @@
import { msg } from '@lingui/core/macro';
import { z } from 'zod';
import { RecipientSchema } from '@documenso/prisma/generated/zod/modelSchema/RecipientSchema';
@@ -110,3 +111,13 @@ export const ZEnvelopeRecipientManySchema = ZRecipientManySchema.omit({
documentId: true,
templateId: true,
});
export const ZRecipientEmailSchema = z.union([
z.literal(''),
z
.string()
.trim()
.toLowerCase()
.email({ message: msg`Invalid email`.id })
.max(254),
]);
+1 -1
View File
@@ -32,5 +32,5 @@ export const getEnvelopeItemPdfUrl = (options: EnvelopeItemPdfUrlOptions) => {
return token
? `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/token/${token}/envelopeItem/${id}${presignToken ? `?presignToken=${presignToken}` : ''}`
: `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/envelope/${envelopeId}/envelopeItem/${id}${presignToken ? `?token=${presignToken}` : ''}`;
: `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/envelope/${envelopeId}/envelopeItem/${id}`;
};
-2
View File
@@ -135,7 +135,5 @@ export const generateDefaultOrganisationSettings = (): Omit<
emailReplyTo: null,
// emailReplyToName: null,
emailDocumentSettings: DEFAULT_DOCUMENT_EMAIL_SETTINGS,
aiFeaturesEnabled: false,
};
};
+5
View File
@@ -1,5 +1,6 @@
import type { Envelope } from '@prisma/client';
import { type Field, type Recipient, RecipientRole, SigningStatus } from '@prisma/client';
import { z } from 'zod';
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
import { extractLegacyIds } from '../universal/id';
@@ -58,3 +59,7 @@ export const mapRecipientToLegacyRecipient = (
...legacyId,
};
};
export const isRecipientEmailValidForSending = (recipient: Pick<Recipient, 'email'>) => {
return z.string().email().safeParse(recipient.email).success;
};
-2
View File
@@ -202,8 +202,6 @@ export const generateDefaultTeamSettings = (): Omit<TeamGlobalSettings, 'id' | '
emailId: null,
emailReplyTo: null,
// emailReplyToName: null,
aiFeaturesEnabled: null,
};
};
@@ -1,6 +0,0 @@
-- AlterTable
ALTER TABLE "OrganisationGlobalSettings" ADD COLUMN "aiFeaturesEnabled" BOOLEAN NOT NULL DEFAULT false;
-- AlterTable
ALTER TABLE "TeamGlobalSettings" ADD COLUMN "aiFeaturesEnabled" BOOLEAN;

Some files were not shown because too many files have changed in this diff Show More