diff --git a/README.md b/README.md
index 9021ee7e6..b0fc9990f 100644
--- a/README.md
+++ b/README.md
@@ -182,6 +182,9 @@ git clone https://github.com//documenso
- Optional: Seed the database using `npm run prisma:seed -w @documenso/prisma` to create a test user and document.
- Optional: Create your own signing certificate.
- To generate your own using these steps and a Linux Terminal or Windows Subsystem for Linux (WSL), see **[Create your own signing certificate](./SIGNING.md)**.
+- Optional: Configure job provider for document reminders.
+ - The default local job provider does not support scheduled jobs required for document reminders.
+ - To enable reminders, set `NEXT_PRIVATE_JOBS_PROVIDER=inngest` and provide `NEXT_PRIVATE_INNGEST_EVENT_KEY` in your `.env` file.
### Run in Gitpod
diff --git a/apps/remix/app/components/dialogs/document-resend-dialog.tsx b/apps/remix/app/components/dialogs/document-resend-dialog.tsx
index d8c0a73ee..57e5235cc 100644
--- a/apps/remix/app/components/dialogs/document-resend-dialog.tsx
+++ b/apps/remix/app/components/dialogs/document-resend-dialog.tsx
@@ -4,13 +4,14 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
-import { type Recipient, SigningStatus, type Team, type User } from '@prisma/client';
+import { SigningStatus, type Team, type User } from '@prisma/client';
import { History } from 'lucide-react';
import { useForm, useWatch } from 'react-hook-form';
import * as z from 'zod';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
+import type { TRecipientLite } from '@documenso/lib/types/recipient';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import type { Document } from '@documenso/prisma/types/document-legacy-schema';
import { trpc as trpcReact } from '@documenso/trpc/react';
@@ -45,10 +46,10 @@ const FORM_ID = 'resend-email';
export type DocumentResendDialogProps = {
document: Pick & {
user: Pick;
- recipients: Recipient[];
+ recipients: TRecipientLite[];
team: Pick | null;
};
- recipients: Recipient[];
+ recipients: TRecipientLite[];
};
export const ZResendDocumentFormSchema = z.object({
@@ -183,7 +184,7 @@ export const DocumentResendDialog = ({ document, recipients }: DocumentResendDia
diff --git a/apps/remix/app/components/dialogs/envelope-redistribute-dialog.tsx b/apps/remix/app/components/dialogs/envelope-redistribute-dialog.tsx
index 6a60ba81b..616fdde4a 100644
--- a/apps/remix/app/components/dialogs/envelope-redistribute-dialog.tsx
+++ b/apps/remix/app/components/dialogs/envelope-redistribute-dialog.tsx
@@ -4,12 +4,13 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
-import { DocumentStatus, EnvelopeType, type Recipient, SigningStatus } from '@prisma/client';
+import { DocumentStatus, EnvelopeType, SigningStatus } from '@prisma/client';
import { useForm } from 'react-hook-form';
import * as z from 'zod';
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import type { TEnvelope } from '@documenso/lib/types/envelope';
+import type { TEnvelopeRecipientLite } from '@documenso/lib/types/recipient';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
@@ -38,7 +39,7 @@ import { StackAvatar } from '../general/stack-avatar';
export type EnvelopeRedistributeDialogProps = {
envelope: Pick & {
- recipients: Recipient[];
+ recipients: TEnvelopeRecipientLite[];
};
trigger?: React.ReactNode;
};
diff --git a/apps/remix/app/components/dialogs/envelope-save-as-template-dialog.tsx b/apps/remix/app/components/dialogs/envelope-save-as-template-dialog.tsx
index a23549f16..b078f00d0 100644
--- a/apps/remix/app/components/dialogs/envelope-save-as-template-dialog.tsx
+++ b/apps/remix/app/components/dialogs/envelope-save-as-template-dialog.tsx
@@ -26,15 +26,22 @@ import { useCurrentTeam } from '~/providers/team';
type EnvelopeSaveAsTemplateDialogProps = {
envelopeId: string;
trigger?: React.ReactNode;
+ open?: boolean;
+ onOpenChange?: (open: boolean) => void;
};
export const EnvelopeSaveAsTemplateDialog = ({
envelopeId,
trigger,
+ open: controlledOpen,
+ onOpenChange: controlledOnOpenChange,
}: EnvelopeSaveAsTemplateDialogProps) => {
const navigate = useNavigate();
- const [open, setOpen] = useState(false);
+ const [internalOpen, setInternalOpen] = useState(false);
+
+ const open = controlledOpen ?? internalOpen;
+ const setOpen = controlledOnOpenChange ?? setInternalOpen;
const { toast } = useToast();
const { t } = useLingui();
diff --git a/apps/remix/app/components/dialogs/template-direct-link-dialog.tsx b/apps/remix/app/components/dialogs/template-direct-link-dialog.tsx
index b644045d6..9047dfe88 100644
--- a/apps/remix/app/components/dialogs/template-direct-link-dialog.tsx
+++ b/apps/remix/app/components/dialogs/template-direct-link-dialog.tsx
@@ -3,7 +3,7 @@ import { useEffect, useMemo, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
-import { type Recipient, RecipientRole, type TemplateDirectLink } from '@prisma/client';
+import { RecipientRole, type TemplateDirectLink } from '@prisma/client';
import {
CircleDotIcon,
CircleIcon,
@@ -21,6 +21,7 @@ import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/org
import { DIRECT_TEMPLATE_RECIPIENT_EMAIL } from '@documenso/lib/constants/direct-templates';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { DIRECT_TEMPLATE_DOCUMENTATION } from '@documenso/lib/constants/template';
+import type { TRecipientLite } from '@documenso/lib/types/recipient';
import { formatDirectTemplatePath } from '@documenso/lib/utils/templates';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
@@ -52,7 +53,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
type TemplateDirectLinkDialogProps = {
templateId: number;
directLink?: Pick | null;
- recipients: Recipient[];
+ recipients: TRecipientLite[];
trigger?: React.ReactNode;
onCreateSuccess?: () => Promise | void;
onDeleteSuccess?: () => Promise | void;
diff --git a/apps/remix/app/components/dialogs/template-use-dialog.tsx b/apps/remix/app/components/dialogs/template-use-dialog.tsx
index 9fd507c63..5f5a23955 100644
--- a/apps/remix/app/components/dialogs/template-use-dialog.tsx
+++ b/apps/remix/app/components/dialogs/template-use-dialog.tsx
@@ -4,7 +4,6 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
-import type { Recipient } from '@prisma/client';
import { DocumentDistributionMethod, DocumentSigningOrder } from '@prisma/client';
import { FileTextIcon, InfoIcon, Plus, UploadCloudIcon, X } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form';
@@ -21,7 +20,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 { type TRecipientLite, 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';
@@ -79,7 +78,7 @@ export type TemplateUseDialogProps = {
envelopeId: string;
templateId: number;
templateSigningOrder?: DocumentSigningOrder | null;
- recipients: Recipient[];
+ recipients: TRecipientLite[];
documentDistributionMethod?: DocumentDistributionMethod;
documentRootPath: string;
trigger?: React.ReactNode;
diff --git a/apps/remix/app/components/embed/authoring/configure-fields-view.tsx b/apps/remix/app/components/embed/authoring/configure-fields-view.tsx
index c05f4f502..d11ac1a44 100644
--- a/apps/remix/app/components/embed/authoring/configure-fields-view.tsx
+++ b/apps/remix/app/components/embed/authoring/configure-fields-view.tsx
@@ -4,7 +4,7 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { EnvelopeItem, FieldType } from '@prisma/client';
-import { ReadStatus, type Recipient, SendStatus, SigningStatus } from '@prisma/client';
+import { ReadStatus, SendStatus, SigningStatus } from '@prisma/client';
import { ChevronsUpDown } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form';
import { useHotkeys } from 'react-hotkeys-hook';
@@ -13,6 +13,7 @@ import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-c
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
import { PDF_VIEWER_PAGE_SELECTOR, getPdfPagesCount } from '@documenso/lib/constants/pdf-viewer';
import { type TFieldMetaSchema, ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
+import type { TRecipientLite } from '@documenso/lib/types/recipient';
import { nanoid } from '@documenso/lib/universal/id';
import { ADVANCED_FIELD_TYPES_WITH_OPTIONAL_SETTING } from '@documenso/lib/utils/advanced-fields-helpers';
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
@@ -105,7 +106,7 @@ export const ConfigureFieldsView = ({
}, [configData.documentData, envelopeItem, presignToken]);
const recipients = useMemo(() => {
- return configData.signers.map((signer, index) => ({
+ return configData.signers.map((signer, index) => ({
id: signer.nativeId || index,
name: signer.name || '',
email: signer.email || '',
@@ -128,7 +129,7 @@ export const ConfigureFieldsView = ({
}));
}, [configData.signers]);
- const [selectedRecipient, setSelectedRecipient] = useState(
+ const [selectedRecipient, setSelectedRecipient] = useState(
() => recipients.find((r) => r.signingStatus === SigningStatus.NOT_SIGNED) || null,
);
const [selectedField, setSelectedField] = useState(null);
diff --git a/apps/remix/app/components/forms/document-preferences-form.tsx b/apps/remix/app/components/forms/document-preferences-form.tsx
index 953136223..57e6fdd2b 100644
--- a/apps/remix/app/components/forms/document-preferences-form.tsx
+++ b/apps/remix/app/components/forms/document-preferences-form.tsx
@@ -15,6 +15,10 @@ import {
type TEnvelopeExpirationPeriod,
ZEnvelopeExpirationPeriod,
} from '@documenso/lib/constants/envelope-expiration';
+import {
+ type TEnvelopeReminderSettings,
+ ZEnvelopeReminderSettings,
+} from '@documenso/lib/constants/envelope-reminder';
import {
SUPPORTED_LANGUAGES,
SUPPORTED_LANGUAGE_CODES,
@@ -32,6 +36,7 @@ import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'
import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams';
import { DocumentSignatureSettingsTooltip } from '@documenso/ui/components/document/document-signature-settings-tooltip';
import { ExpirationPeriodPicker } from '@documenso/ui/components/document/expiration-period-picker';
+import { ReminderSettingsPicker } from '@documenso/ui/components/document/reminder-settings-picker';
import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select';
import { Alert } from '@documenso/ui/primitives/alert';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
@@ -76,6 +81,7 @@ export type TDocumentPreferencesFormSchema = {
delegateDocumentOwnership: boolean | null;
aiFeaturesEnabled: boolean | null;
envelopeExpirationPeriod: TEnvelopeExpirationPeriod | null;
+ reminderSettings: TEnvelopeReminderSettings | null;
};
type SettingsSubset = Pick<
@@ -94,6 +100,7 @@ type SettingsSubset = Pick<
| 'delegateDocumentOwnership'
| 'aiFeaturesEnabled'
| 'envelopeExpirationPeriod'
+ | 'reminderSettings'
>;
export type DocumentPreferencesFormProps = {
@@ -134,6 +141,7 @@ export const DocumentPreferencesForm = ({
delegateDocumentOwnership: z.boolean().nullable(),
aiFeaturesEnabled: z.boolean().nullable(),
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullable(),
+ reminderSettings: ZEnvelopeReminderSettings.nullable(),
});
const form = useForm({
@@ -155,6 +163,7 @@ export const DocumentPreferencesForm = ({
delegateDocumentOwnership: settings.delegateDocumentOwnership,
aiFeaturesEnabled: settings.aiFeaturesEnabled,
envelopeExpirationPeriod: settings.envelopeExpirationPeriod ?? null,
+ reminderSettings: settings.reminderSettings ?? null,
},
resolver: zodResolver(ZDocumentPreferencesFormSchema),
});
@@ -707,6 +716,35 @@ export const DocumentPreferencesForm = ({
)}
/>
+ (
+
+
+ Default Signing Reminders
+
+
+
+
+
+
+
+
+ Controls when and how often reminder emails are sent to recipients who have not
+ yet completed signing.
+
+
+
+
+
+ )}
+ />
+
{isAiFeaturesConfigured && (
recipient.email === user.email);
@@ -135,17 +136,10 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
}
/>
- e.preventDefault()}>
-
-
- Save as Template
-
-
- }
- />
+ setSaveAsTemplateDialogOpen(true)}>
+
+ Save as Template
+
+
+
-
+
{recipients.length === 0 && (
No recipients
@@ -99,9 +99,9 @@ export const DocumentRecipientLinkCopyDialog = ({
{recipient.email}
}
+ primaryText={{recipient.email}
}
secondaryText={
-
+
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
}
diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
index 27c633806..5592d04b9 100644
--- a/apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
+++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
@@ -13,7 +13,7 @@ import {
TemplateType,
} from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
-import { InfoIcon, MailIcon, SettingsIcon, ShieldIcon } from 'lucide-react';
+import { BellRingIcon, InfoIcon, MailIcon, SettingsIcon, ShieldIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { z } from 'zod';
@@ -26,6 +26,7 @@ import {
DOCUMENT_SIGNATURE_TYPES,
} from '@documenso/lib/constants/document';
import { ZEnvelopeExpirationPeriod } from '@documenso/lib/constants/envelope-expiration';
+import { ZEnvelopeReminderSettings } from '@documenso/lib/constants/envelope-reminder';
import {
SUPPORTED_LANGUAGES,
SUPPORTED_LANGUAGE_CODES,
@@ -69,6 +70,7 @@ import {
DocumentVisibilityTooltip,
} from '@documenso/ui/components/document/document-visibility-select';
import { ExpirationPeriodPicker } from '@documenso/ui/components/document/expiration-period-picker';
+import { ReminderSettingsPicker } from '@documenso/ui/components/document/reminder-settings-picker';
import {
TemplateTypeSelect,
TemplateTypeTooltip,
@@ -145,10 +147,11 @@ export const ZAddSettingsFormSchema = z.object({
message: msg`At least one signature type must be enabled`.id,
}),
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullish(),
+ reminderSettings: ZEnvelopeReminderSettings.nullish(),
}),
});
-type EnvelopeEditorSettingsTabType = 'general' | 'email' | 'security';
+type EnvelopeEditorSettingsTabType = 'general' | 'reminders' | 'email' | 'security';
const tabs = [
{
@@ -157,6 +160,12 @@ const tabs = [
icon: SettingsIcon,
description: msg`Configure document settings and options before sending.`,
},
+ {
+ id: 'reminders',
+ title: msg`Reminders`,
+ icon: BellRingIcon,
+ description: msg`Configure signing reminder settings for the document.`,
+ },
{
id: 'email',
title: msg`Email`,
@@ -222,6 +231,7 @@ export const EnvelopeEditorSettingsDialog = ({
emailSettings: ZDocumentEmailSettingsSchema.parse(envelope.documentMeta.emailSettings),
signatureTypes: extractTeamSignatureSettings(envelope.documentMeta),
envelopeExpirationPeriod: envelope.documentMeta?.envelopeExpirationPeriod ?? null,
+ reminderSettings: envelope.documentMeta?.reminderSettings ?? null,
},
};
};
@@ -270,6 +280,7 @@ export const EnvelopeEditorSettingsDialog = ({
subject,
emailReplyTo,
envelopeExpirationPeriod,
+ reminderSettings,
} = data.meta;
const parsedGlobalAccessAuth = z
@@ -300,6 +311,7 @@ export const EnvelopeEditorSettingsDialog = ({
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
envelopeExpirationPeriod,
+ reminderSettings,
},
});
@@ -381,6 +393,10 @@ export const EnvelopeEditorSettingsDialog = ({
return null;
}
+ if (tab.id === 'reminders' && !settings.allowConfigureReminders) {
+ return null;
+ }
+
return (
))
+ .with(
+ { activeTab: 'reminders', settings: { allowConfigureReminders: true } },
+ () => (
+ (
+
+
+ Signing Reminders
+
+
+
+
+
+
+
+ Configure when and how often reminder emails are sent to
+ recipients who have not yet completed signing. Uses the team
+ default when set to inherit.
+
+
+
+
+
+
+
+
+
+
+
+ )}
+ />
+ ),
+ )
.with(
{ activeTab: 'email', settings: { allowConfigureDistribution: true } },
() => (
diff --git a/apps/remix/app/components/general/envelope-editor/envelope-recipient-selector.tsx b/apps/remix/app/components/general/envelope-editor/envelope-recipient-selector.tsx
index 5c39a945b..8367d71e9 100644
--- a/apps/remix/app/components/general/envelope-editor/envelope-recipient-selector.tsx
+++ b/apps/remix/app/components/general/envelope-editor/envelope-recipient-selector.tsx
@@ -4,12 +4,13 @@ import type { I18n } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
-import type { Field, Recipient } from '@prisma/client';
+import type { Field } from '@prisma/client';
import { RecipientRole, SendStatus } from '@prisma/client';
import { Check, ChevronsUpDown, Info } from 'lucide-react';
import { sortBy } from 'remeda';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
+import type { TEnvelopeRecipientLite } from '@documenso/lib/types/recipient';
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
import { getRecipientColorStyles } from '@documenso/ui/lib/recipient-colors';
import { cn } from '@documenso/ui/lib/utils';
@@ -26,9 +27,9 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitive
export interface EnvelopeRecipientSelectorProps {
className?: string;
- selectedRecipient: Recipient | null;
- onSelectedRecipientChange: (recipient: Recipient) => void;
- recipients: Recipient[];
+ selectedRecipient: TEnvelopeRecipientLite | null;
+ onSelectedRecipientChange: (recipient: TEnvelopeRecipientLite) => void;
+ recipients: TEnvelopeRecipientLite[];
fields: Field[];
align?: 'center' | 'end' | 'start';
}
@@ -46,7 +47,7 @@ export const EnvelopeRecipientSelector = ({
const [showRecipientsSelector, setShowRecipientsSelector] = useState(false);
const getRecipientLabel = useCallback(
- (recipient: Recipient) => extractRecipientLabel(recipient, recipients, i18n),
+ (recipient: TEnvelopeRecipientLite) => extractRecipientLabel(recipient, recipients, i18n),
[recipients],
);
@@ -91,9 +92,9 @@ export const EnvelopeRecipientSelector = ({
interface EnvelopeRecipientSelectorCommandProps {
className?: string;
- selectedRecipient: Recipient | null;
- onSelectedRecipientChange: (recipient: Recipient) => void;
- recipients: Recipient[];
+ selectedRecipient: TEnvelopeRecipientLite | null;
+ onSelectedRecipientChange: (recipient: TEnvelopeRecipientLite) => void;
+ recipients: TEnvelopeRecipientLite[];
fields: Field[];
placeholder?: string;
}
@@ -109,7 +110,7 @@ export const EnvelopeRecipientSelectorCommand = ({
const { t, i18n } = useLingui();
const recipientsByRole = useCallback(() => {
- const recipientsByRole: Record = {
+ const recipientsByRole: Record = {
CC: [],
VIEWER: [],
SIGNER: [],
@@ -141,7 +142,7 @@ export const EnvelopeRecipientSelectorCommand = ({
[(r) => r.signingOrder || Number.MAX_SAFE_INTEGER, 'asc'],
[(r) => r.id, 'asc'],
),
- ] as [RecipientRole, Recipient[]],
+ ] as [RecipientRole, TEnvelopeRecipientLite[]],
);
}, [recipientsByRole]);
@@ -156,7 +157,7 @@ export const EnvelopeRecipientSelectorCommand = ({
);
const getRecipientLabel = useCallback(
- (recipient: Recipient) => extractRecipientLabel(recipient, recipients, i18n),
+ (recipient: TEnvelopeRecipientLite) => extractRecipientLabel(recipient, recipients, i18n),
[recipients],
);
@@ -247,7 +248,11 @@ export const EnvelopeRecipientSelectorCommand = ({
);
};
-const extractRecipientLabel = (recipient: Recipient, recipients: Recipient[], i18n: I18n) => {
+const extractRecipientLabel = (
+ recipient: TEnvelopeRecipientLite,
+ recipients: TEnvelopeRecipientLite[],
+ i18n: I18n,
+) => {
if (recipient.name && recipient.email) {
return `${recipient.name} (${recipient.email})`;
}
diff --git a/apps/remix/app/components/general/stack-avatars-with-tooltip.tsx b/apps/remix/app/components/general/stack-avatars-with-tooltip.tsx
index ca3abea1b..2f165deb7 100644
--- a/apps/remix/app/components/general/stack-avatars-with-tooltip.tsx
+++ b/apps/remix/app/components/general/stack-avatars-with-tooltip.tsx
@@ -2,10 +2,11 @@ import { useMemo } from 'react';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
-import { type DocumentStatus, type Recipient } from '@prisma/client';
+import type { DocumentStatus } from '@prisma/client';
import { RecipientStatusType, getRecipientType } from '@documenso/lib/client-only/recipient-type';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
+import type { TRecipientLite } from '@documenso/lib/types/recipient';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import { PopoverHover } from '@documenso/ui/primitives/popover';
@@ -15,7 +16,7 @@ import { StackAvatars } from './stack-avatars';
export type StackAvatarsWithTooltipProps = {
documentStatus: DocumentStatus;
- recipients: Recipient[];
+ recipients: TRecipientLite[];
position?: 'top' | 'bottom';
children?: React.ReactNode;
};
@@ -74,7 +75,7 @@ export const StackAvatarsWithTooltip = ({
Completed
- {completedRecipients.map((recipient: Recipient) => (
+ {completedRecipients.map((recipient) => (
Rejected
- {rejectedRecipients.map((recipient: Recipient) => (
+ {rejectedRecipients.map((recipient) => (
Waiting
- {waitingRecipients.map((recipient: Recipient) => (
+ {waitingRecipients.map((recipient) => (
Opened
- {openedRecipients.map((recipient: Recipient) => (
+ {openedRecipients.map((recipient) => (
Uncompleted
- {uncompletedRecipients.map((recipient: Recipient) => (
+ {uncompletedRecipients.map((recipient) => (
{
+export function StackAvatars({ recipients }: { recipients: TRecipientLite[] }) {
+ const renderStackAvatars = (recipients: TRecipientLite[]) => {
const zIndex = 50;
const itemsToRender = recipients.slice(0, 5);
const remainingItems = recipients.length - itemsToRender.length;
- return itemsToRender.map((recipient: Recipient, index: number) => {
+ return itemsToRender.map((recipient, index: number) => {
const first = index === 0;
if (index === 4 && remainingItems > 0) {
diff --git a/apps/remix/app/components/general/template/template-page-view-recipients.tsx b/apps/remix/app/components/general/template/template-page-view-recipients.tsx
index e5ac2d5a9..6419cf40e 100644
--- a/apps/remix/app/components/general/template/template-page-view-recipients.tsx
+++ b/apps/remix/app/components/general/template/template-page-view-recipients.tsx
@@ -1,17 +1,17 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
-import type { Recipient } from '@prisma/client';
import { PenIcon, PlusIcon } from 'lucide-react';
import { Link } from 'react-router';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { isTemplateRecipientEmailPlaceholder } from '@documenso/lib/constants/template';
+import type { TRecipientLite } from '@documenso/lib/types/recipient';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
export type TemplatePageViewRecipientsProps = {
- recipients: Recipient[];
+ recipients: TRecipientLite[];
envelopeId: string;
templateRootPath: string;
readOnly?: boolean;
diff --git a/apps/remix/app/components/tables/documents-table-action-dropdown.tsx b/apps/remix/app/components/tables/documents-table-action-dropdown.tsx
index b3bea4060..509be3880 100644
--- a/apps/remix/app/components/tables/documents-table-action-dropdown.tsx
+++ b/apps/remix/app/components/tables/documents-table-action-dropdown.tsx
@@ -62,6 +62,7 @@ export const DocumentsTableActionDropdown = ({
const trpcUtils = trpcReact.useUtils();
const [isRenameDialogOpen, setRenameDialogOpen] = useState(false);
+ const [isSaveAsTemplateDialogOpen, setSaveAsTemplateDialogOpen] = useState(false);
const recipient = findRecipientByEmail({
recipients: row.recipients,
@@ -175,17 +176,10 @@ export const DocumentsTableActionDropdown = ({
}
/>
- e.preventDefault()}>
-
-
- Save as Template
-
-
- }
- />
+ setSaveAsTemplateDialogOpen(true)}>
+
+ Save as Template
+
{onMoveDocument && canManageDocument && (
e.preventDefault()}>
@@ -250,6 +244,12 @@ export const DocumentsTableActionDropdown = ({
/>
+
+
| null;
- recipients: Recipient[];
+ recipients: TRecipientLite[];
};
templateRootPath: string;
teamId: number;
diff --git a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.document.tsx b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.document.tsx
index 6adaaa96a..8c231776a 100644
--- a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.document.tsx
+++ b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.document.tsx
@@ -62,6 +62,7 @@ export default function OrganisationSettingsDocumentPage() {
delegateDocumentOwnership,
aiFeaturesEnabled,
envelopeExpirationPeriod,
+ reminderSettings,
} = data;
if (
@@ -93,6 +94,7 @@ export default function OrganisationSettingsDocumentPage() {
delegateDocumentOwnership: delegateDocumentOwnership,
aiFeaturesEnabled,
envelopeExpirationPeriod: envelopeExpirationPeriod ?? undefined,
+ reminderSettings: reminderSettings ?? undefined,
},
});
diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.document.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.document.tsx
index a99b83909..0bfa25ebb 100644
--- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.document.tsx
+++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.document.tsx
@@ -55,6 +55,7 @@ export default function TeamsSettingsPage() {
delegateDocumentOwnership,
aiFeaturesEnabled,
envelopeExpirationPeriod,
+ reminderSettings,
} = data;
await updateTeamSettings({
@@ -70,6 +71,7 @@ export default function TeamsSettingsPage() {
defaultRecipients,
aiFeaturesEnabled,
envelopeExpirationPeriod,
+ reminderSettings,
...(signatureTypes.length === 0
? {
typedSignatureEnabled: null,
diff --git a/packages/app-tests/e2e/envelope-editor-v2/envelope-settings.spec.ts b/packages/app-tests/e2e/envelope-editor-v2/envelope-settings.spec.ts
index a5a6f8aad..03bcc79ae 100644
--- a/packages/app-tests/e2e/envelope-editor-v2/envelope-settings.spec.ts
+++ b/packages/app-tests/e2e/envelope-editor-v2/envelope-settings.spec.ts
@@ -31,6 +31,12 @@ const TEST_SETTINGS_VALUES = {
expirationMode: 'Custom duration',
expirationAmount: 5,
expirationUnit: 'Weeks',
+ reminderMode: 'Enabled',
+ reminderSendAfterAmount: 3,
+ reminderSendAfterUnit: 'Days',
+ reminderRepeatMode: 'Custom interval',
+ reminderRepeatAmount: 7,
+ reminderRepeatUnit: 'Days',
accessAuth: 'Require account',
actionAuth: 'Require password',
visibility: 'Managers and above',
@@ -42,6 +48,10 @@ const DB_EXPECTED_VALUES = {
timezone: 'Europe/London',
distributionMethod: DocumentDistributionMethod.NONE,
envelopeExpirationPeriod: { unit: 'week', amount: 5 },
+ reminderSettings: {
+ sendAfter: { unit: 'day', amount: 3 },
+ repeatEvery: { unit: 'day', amount: 7 },
+ },
visibility: DocumentVisibility.MANAGER_AND_ABOVE,
globalAccessAuth: ['ACCOUNT'],
globalActionAuth: ['PASSWORD'],
@@ -130,6 +140,66 @@ const runSettingsFlow = async (
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.expirationUnit }).click();
await clickSettingsDialogHeader(root);
+ // Configure reminder settings.
+ await root.getByRole('button', { name: 'Reminders' }).click();
+
+ await root.locator('[data-testid="reminder-mode-select"]').click();
+ await root.getByRole('option', { name: TEST_SETTINGS_VALUES.reminderMode }).click();
+ await clickSettingsDialogHeader(root);
+
+ await root.locator('[data-testid="reminder-send-after-amount"]').clear();
+ await root
+ .locator('[data-testid="reminder-send-after-amount"]')
+ .fill(String(TEST_SETTINGS_VALUES.reminderSendAfterAmount));
+
+ await root.locator('[data-testid="reminder-send-after-unit"]').click();
+ await root.getByRole('option', { name: TEST_SETTINGS_VALUES.reminderSendAfterUnit }).click();
+ await clickSettingsDialogHeader(root);
+
+ await root.locator('[data-testid="reminder-repeat-mode-select"]').click();
+ await root.getByRole('option', { name: TEST_SETTINGS_VALUES.reminderRepeatMode }).click();
+ await clickSettingsDialogHeader(root);
+
+ await root.locator('[data-testid="reminder-repeat-amount"]').clear();
+ await root
+ .locator('[data-testid="reminder-repeat-amount"]')
+ .fill(String(TEST_SETTINGS_VALUES.reminderRepeatAmount));
+
+ await root.locator('[data-testid="reminder-repeat-unit"]').click();
+ await root.getByRole('option', { name: TEST_SETTINGS_VALUES.reminderRepeatUnit }).click();
+ await clickSettingsDialogHeader(root);
+
+ const spinbuttons = root.getByRole('spinbutton');
+ await spinbuttons.first().clear();
+ await spinbuttons.first().fill(String(TEST_SETTINGS_VALUES.reminderSendAfterAmount));
+
+ const sendAfterUnitTrigger = root
+ .locator('button[role="combobox"]')
+ .filter({ hasText: /Days|Weeks|Months/ })
+ .first();
+ await sendAfterUnitTrigger.click();
+ await root.getByRole('option', { name: TEST_SETTINGS_VALUES.reminderSendAfterUnit }).click();
+ await clickSettingsDialogHeader(root);
+
+ const repeatModeSelect = root
+ .locator('button[role="combobox"]')
+ .filter({ hasText: /Custom interval|Don't repeat/ })
+ .first();
+ await repeatModeSelect.click();
+ await root.getByRole('option', { name: TEST_SETTINGS_VALUES.reminderRepeatMode }).click();
+ await clickSettingsDialogHeader(root);
+
+ await spinbuttons.last().clear();
+ await spinbuttons.last().fill(String(TEST_SETTINGS_VALUES.reminderRepeatAmount));
+
+ const repeatUnitTrigger = root
+ .locator('button[role="combobox"]')
+ .filter({ hasText: /Days|Weeks|Months/ })
+ .last();
+ await repeatUnitTrigger.click();
+ await root.getByRole('option', { name: TEST_SETTINGS_VALUES.reminderRepeatUnit }).click();
+ await clickSettingsDialogHeader(root);
+
await root.getByRole('button', { name: 'Email' }).click();
await root.locator('#recipientSigned').click();
await root.locator('#recipientSigningRequest').click();
@@ -200,6 +270,27 @@ const runSettingsFlow = async (
.first(),
).toBeVisible();
+ // Verify reminder settings persisted in UI.
+ await root.getByRole('button', { name: 'Reminders' }).click();
+ await expect(root.locator('[data-testid="reminder-mode-select"]')).toContainText(
+ TEST_SETTINGS_VALUES.reminderMode,
+ );
+ await expect(root.locator('[data-testid="reminder-send-after-amount"]')).toHaveValue(
+ String(TEST_SETTINGS_VALUES.reminderSendAfterAmount),
+ );
+ await expect(root.locator('[data-testid="reminder-send-after-unit"]')).toContainText(
+ TEST_SETTINGS_VALUES.reminderSendAfterUnit,
+ );
+ await expect(root.locator('[data-testid="reminder-repeat-mode-select"]')).toContainText(
+ TEST_SETTINGS_VALUES.reminderRepeatMode,
+ );
+ await expect(root.locator('[data-testid="reminder-repeat-amount"]')).toHaveValue(
+ String(TEST_SETTINGS_VALUES.reminderRepeatAmount),
+ );
+ await expect(root.locator('[data-testid="reminder-repeat-unit"]')).toContainText(
+ TEST_SETTINGS_VALUES.reminderRepeatUnit,
+ );
+
await root.getByRole('button', { name: 'Email' }).click();
await expect(root.locator('#recipientSigned')).toHaveAttribute('aria-checked', 'false');
await expect(root.locator('#recipientSigningRequest')).toHaveAttribute('aria-checked', 'false');
@@ -285,6 +376,7 @@ const assertEnvelopeSettingsPersistedInDatabase = async ({
expect(envelope.documentMeta.envelopeExpirationPeriod).toEqual(
DB_EXPECTED_VALUES.envelopeExpirationPeriod,
);
+ expect(envelope.documentMeta.reminderSettings).toEqual(DB_EXPECTED_VALUES.reminderSettings);
expect(envelope.documentMeta.redirectUrl).toBe(TEST_SETTINGS_VALUES.redirectUrl);
expect(envelope.documentMeta.emailReplyTo).toBe(TEST_SETTINGS_VALUES.replyTo);
expect(envelope.documentMeta.subject).toBe(TEST_SETTINGS_VALUES.subject);
diff --git a/packages/app-tests/e2e/envelopes/envelope-expiration-settings.spec.ts b/packages/app-tests/e2e/envelopes/envelope-expiration-settings.spec.ts
index 7710a3ddb..39b6f8900 100644
--- a/packages/app-tests/e2e/envelopes/envelope-expiration-settings.spec.ts
+++ b/packages/app-tests/e2e/envelopes/envelope-expiration-settings.spec.ts
@@ -25,7 +25,7 @@ test('[ENVELOPE_EXPIRATION]: set custom expiration period at organisation level'
await expect(page.getByRole('button', { name: 'Update' }).first()).toBeVisible();
// Change the amount to 2.
- const amountInput = page.getByRole('spinbutton');
+ const amountInput = page.getByTestId('envelope-expiration-amount');
await amountInput.clear();
await amountInput.fill('2');
@@ -33,9 +33,7 @@ test('[ENVELOPE_EXPIRATION]: set custom expiration period at organisation level'
// In the duration mode, there's a mode select and a unit select.
// The unit select is inside the duration row, after the number input.
// Let's find the select trigger that contains the unit text.
- const unitTrigger = page
- .locator('button[role="combobox"]')
- .filter({ hasText: /Months|Days|Weeks|Years/ });
+ const unitTrigger = page.getByTestId('envelope-expiration-unit');
await unitTrigger.click();
await page.getByRole('option', { name: 'Weeks' }).click();
@@ -65,9 +63,7 @@ test('[ENVELOPE_EXPIRATION]: disable expiration at organisation level', async ({
await expect(page.getByRole('button', { name: 'Update' }).first()).toBeVisible();
// Find the mode select (shows "Custom duration") and change to "Never expires".
- const modeTrigger = page
- .locator('button[role="combobox"]')
- .filter({ hasText: 'Custom duration' });
+ const modeTrigger = page.getByTestId('envelope-expiration-mode');
await modeTrigger.click();
await page.getByRole('option', { name: 'Never expires' }).click();
@@ -118,11 +114,8 @@ test('[ENVELOPE_EXPIRATION]: team overrides organisation expiration', async ({ p
await expect(page.getByRole('button', { name: 'Update' }).first()).toBeVisible();
- // Scope to the "Default Envelope Expiration" form field section.
- const expirationSection = page.getByText('Default Envelope Expiration').locator('..');
-
// The expiration picker mode select should show "Inherit from organisation" by default.
- const modeTrigger = expirationSection.locator('button[role="combobox"]').first();
+ const modeTrigger = page.getByTestId('envelope-expiration-mode');
await expect(modeTrigger).toBeVisible();
// Switch to custom duration.
@@ -130,13 +123,11 @@ test('[ENVELOPE_EXPIRATION]: team overrides organisation expiration', async ({ p
await page.getByRole('option', { name: 'Custom duration' }).click();
// Set to 5 days.
- const amountInput = expirationSection.getByRole('spinbutton');
+ const amountInput = page.getByTestId('envelope-expiration-amount');
await amountInput.clear();
await amountInput.fill('5');
- const unitTrigger = expirationSection
- .locator('button[role="combobox"]')
- .filter({ hasText: /Months|Days|Weeks|Years/ });
+ const unitTrigger = page.getByTestId('envelope-expiration-unit');
await unitTrigger.click();
await page.getByRole('option', { name: 'Days' }).click();
diff --git a/packages/email/template-components/template-document-reminder.tsx b/packages/email/template-components/template-document-reminder.tsx
new file mode 100644
index 000000000..542272182
--- /dev/null
+++ b/packages/email/template-components/template-document-reminder.tsx
@@ -0,0 +1,77 @@
+import { useLingui } from '@lingui/react';
+import { Trans } from '@lingui/react/macro';
+import { RecipientRole } from '@prisma/client';
+import { match } from 'ts-pattern';
+
+import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
+
+import { Button, Section, Text } from '../components';
+import { TemplateDocumentImage } from './template-document-image';
+
+export interface TemplateDocumentReminderProps {
+ recipientName: string;
+ documentName: string;
+ signDocumentLink: string;
+ assetBaseUrl: string;
+ role: RecipientRole;
+}
+
+export const TemplateDocumentReminder = ({
+ recipientName,
+ documentName,
+ signDocumentLink,
+ assetBaseUrl,
+ role,
+}: TemplateDocumentReminderProps) => {
+ const { _ } = useLingui();
+
+ const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[role];
+
+ return (
+ <>
+
+
+
+
+
+ Reminder: Please {_(actionVerb).toLowerCase()} your document
+ "{documentName}"
+
+
+
+
+ Hi {recipientName},
+
+
+
+ {match(role)
+ .with(RecipientRole.SIGNER, () => Continue by signing the document. )
+ .with(RecipientRole.VIEWER, () => Continue by viewing the document. )
+ .with(RecipientRole.APPROVER, () => Continue by approving the document. )
+ .with(RecipientRole.CC, () => '')
+ .with(RecipientRole.ASSISTANT, () => (
+ Continue by assisting with the document.
+ ))
+ .exhaustive()}
+
+
+
+
+ {match(role)
+ .with(RecipientRole.SIGNER, () => Sign Document )
+ .with(RecipientRole.VIEWER, () => View Document )
+ .with(RecipientRole.APPROVER, () => Approve Document )
+ .with(RecipientRole.CC, () => '')
+ .with(RecipientRole.ASSISTANT, () => Assist Document )
+ .exhaustive()}
+
+
+
+ >
+ );
+};
+
+export default TemplateDocumentReminder;
diff --git a/packages/email/templates/document-reminder.tsx b/packages/email/templates/document-reminder.tsx
new file mode 100644
index 000000000..c12c8f327
--- /dev/null
+++ b/packages/email/templates/document-reminder.tsx
@@ -0,0 +1,91 @@
+import { msg } from '@lingui/core/macro';
+import { useLingui } from '@lingui/react';
+import { RecipientRole } from '@prisma/client';
+
+import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
+
+import { Body, Container, Head, Hr, Html, Img, Preview, Section, Text } from '../components';
+import { useBranding } from '../providers/branding';
+import { TemplateCustomMessageBody } from '../template-components/template-custom-message-body';
+import { TemplateDocumentReminder } from '../template-components/template-document-reminder';
+import { TemplateFooter } from '../template-components/template-footer';
+
+export type DocumentReminderEmailTemplateProps = {
+ recipientName: string;
+ documentName: string;
+ signDocumentLink: string;
+ assetBaseUrl?: string;
+ customBody?: string;
+ role: RecipientRole;
+};
+
+export const DocumentReminderEmailTemplate = ({
+ recipientName = 'John Doe',
+ documentName = 'Open Source Pledge.pdf',
+ signDocumentLink = 'https://documenso.com',
+ assetBaseUrl = 'http://localhost:3002',
+ customBody,
+ role = RecipientRole.SIGNER,
+}: DocumentReminderEmailTemplateProps) => {
+ const { _ } = useLingui();
+ const branding = useBranding();
+
+ const action = _(RECIPIENT_ROLES_DESCRIPTION[role].actionVerb).toLowerCase();
+
+ const previewText = msg`Reminder to ${action} ${documentName}`;
+
+ const getAssetUrl = (path: string) => {
+ return new URL(path, assetBaseUrl).toString();
+ };
+
+ return (
+
+
+ {_(previewText)}
+
+
+
+
+
+ {branding.brandingEnabled && branding.brandingLogo ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ {customBody && (
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default DocumentReminderEmailTemplate;
diff --git a/packages/lib/client-only/hooks/use-editor-fields.ts b/packages/lib/client-only/hooks/use-editor-fields.ts
index 6edb951c5..c326f6332 100644
--- a/packages/lib/client-only/hooks/use-editor-fields.ts
+++ b/packages/lib/client-only/hooks/use-editor-fields.ts
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
-import type { Field, Recipient } from '@prisma/client';
+import type { Field } from '@prisma/client';
import { FieldType } from '@prisma/client';
import { useFieldArray, useForm } from 'react-hook-form';
import { z } from 'zod';
@@ -61,7 +61,7 @@ type UseEditorFieldsResponse = {
getFieldsByRecipient: (recipientId: number) => TLocalField[];
// Selected recipient
- selectedRecipient: Recipient | null;
+ selectedRecipient: TEditorEnvelope['recipients'][number] | null;
setSelectedRecipient: (recipientId: number | null) => void;
resetForm: (fields?: Field[]) => void;
diff --git a/packages/lib/client-only/hooks/use-editor-recipients.ts b/packages/lib/client-only/hooks/use-editor-recipients.ts
index dfea75acc..a7fbadbd7 100644
--- a/packages/lib/client-only/hooks/use-editor-recipients.ts
+++ b/packages/lib/client-only/hooks/use-editor-recipients.ts
@@ -1,7 +1,7 @@
import { useId } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
-import { DocumentSigningOrder, type Recipient, RecipientRole } from '@prisma/client';
+import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
import type { UseFormReturn } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import { prop, sortBy } from 'remeda';
@@ -39,7 +39,7 @@ type EditorRecipientsProps = {
};
type ResetFormOptions = {
- recipients?: Recipient[];
+ recipients?: TEditorEnvelope['recipients'];
documentMeta?: TEditorEnvelope['documentMeta'];
};
diff --git a/packages/lib/client-only/recipient-type.ts b/packages/lib/client-only/recipient-type.ts
index 1701fc1be..0476264ff 100644
--- a/packages/lib/client-only/recipient-type.ts
+++ b/packages/lib/client-only/recipient-type.ts
@@ -7,6 +7,8 @@ import {
SigningStatus,
} from '@prisma/client';
+type RecipientForType = Pick;
+
export enum RecipientStatusType {
COMPLETED = 'completed',
OPENED = 'opened',
@@ -16,7 +18,7 @@ export enum RecipientStatusType {
}
export const getRecipientType = (
- recipient: Recipient,
+ recipient: RecipientForType,
distributionMethod: DocumentDistributionMethod = DocumentDistributionMethod.EMAIL,
) => {
if (recipient.role === RecipientRole.CC || recipient.signingStatus === SigningStatus.SIGNED) {
@@ -45,7 +47,7 @@ export const getRecipientType = (
return RecipientStatusType.UNSIGNED;
};
-export const getExtraRecipientsType = (extraRecipients: Recipient[]) => {
+export const getExtraRecipientsType = (extraRecipients: RecipientForType[]) => {
const types = extraRecipients.map((r) => getRecipientType(r));
if (types.includes(RecipientStatusType.UNSIGNED)) {
diff --git a/packages/lib/constants/document-audit-logs.ts b/packages/lib/constants/document-audit-logs.ts
index 9b91d2cb9..3ec716e0a 100644
--- a/packages/lib/constants/document-audit-logs.ts
+++ b/packages/lib/constants/document-audit-logs.ts
@@ -19,4 +19,7 @@ export const DOCUMENT_AUDIT_LOG_EMAIL_FORMAT = {
[DOCUMENT_EMAIL_TYPE.DOCUMENT_COMPLETED]: {
description: 'Document completed',
},
+ [DOCUMENT_EMAIL_TYPE.REMINDER]: {
+ description: 'Signing Reminder',
+ },
} satisfies Record;
diff --git a/packages/lib/constants/envelope-reminder.ts b/packages/lib/constants/envelope-reminder.ts
new file mode 100644
index 000000000..8f2672d66
--- /dev/null
+++ b/packages/lib/constants/envelope-reminder.ts
@@ -0,0 +1,86 @@
+import type { DurationLikeObject } from 'luxon';
+import { Duration } from 'luxon';
+import { z } from 'zod';
+
+export const ZEnvelopeReminderDurationPeriod = z.object({
+ unit: z.enum(['day', 'week', 'month']),
+ amount: z.number().int().min(1),
+});
+
+export const ZEnvelopeReminderDisabledPeriod = z.object({
+ disabled: z.literal(true),
+});
+
+export const ZEnvelopeReminderPeriod = z.union([
+ ZEnvelopeReminderDurationPeriod,
+ ZEnvelopeReminderDisabledPeriod,
+]);
+
+export type TEnvelopeReminderPeriod = z.infer;
+export type TEnvelopeReminderDurationPeriod = z.infer;
+
+export const ZEnvelopeReminderSettings = z.object({
+ sendAfter: ZEnvelopeReminderPeriod,
+ repeatEvery: ZEnvelopeReminderPeriod,
+});
+
+export type TEnvelopeReminderSettings = z.infer;
+
+export const DEFAULT_ENVELOPE_REMINDER_SETTINGS: TEnvelopeReminderSettings = {
+ sendAfter: { unit: 'day', amount: 5 },
+ repeatEvery: { unit: 'day', amount: 2 },
+};
+
+const UNIT_TO_LUXON_KEY: Record =
+ {
+ day: 'days',
+ week: 'weeks',
+ month: 'months',
+ };
+
+export const getEnvelopeReminderDuration = (period: TEnvelopeReminderDurationPeriod): Duration => {
+ return Duration.fromObject({ [UNIT_TO_LUXON_KEY[period.unit]]: period.amount });
+};
+
+/**
+ * Resolve the next reminder timestamp from the config and the last reminder sent time.
+ *
+ * - `null` config means reminders are disabled (inherit = no override, resolved as disabled).
+ * - `{ sendAfter: { disabled: true }, ... }` means never send the first reminder.
+ * - `{ repeatEvery: { disabled: true }, ... }` means don't repeat after the first reminder.
+ *
+ * `sentAt` is when the signing request was sent to this specific recipient.
+ *
+ * Returns the next Date the reminder should be sent, or null if no reminder should be sent.
+ */
+export const resolveNextReminderAt = (options: {
+ config: TEnvelopeReminderSettings | null;
+ sentAt: Date;
+ lastReminderSentAt: Date | null;
+}): Date | null => {
+ const { config, sentAt, lastReminderSentAt } = options;
+
+ if (!config) {
+ return null;
+ }
+
+ // If we haven't sent the first reminder yet, use sendAfter.
+ if (!lastReminderSentAt) {
+ if ('disabled' in config.sendAfter) {
+ return null;
+ }
+
+ const delay = getEnvelopeReminderDuration(config.sendAfter);
+
+ return new Date(sentAt.getTime() + delay.toMillis());
+ }
+
+ // For subsequent reminders, use repeatEvery.
+ if ('disabled' in config.repeatEvery) {
+ return null;
+ }
+
+ const interval = getEnvelopeReminderDuration(config.repeatEvery);
+
+ return new Date(lastReminderSentAt.getTime() + interval.toMillis());
+};
diff --git a/packages/lib/jobs/client.ts b/packages/lib/jobs/client.ts
index b9f5c6368..d0b2c4f98 100644
--- a/packages/lib/jobs/client.ts
+++ b/packages/lib/jobs/client.ts
@@ -16,8 +16,10 @@ import { CLEANUP_RATE_LIMITS_JOB_DEFINITION } from './definitions/internal/clean
import { EXECUTE_WEBHOOK_JOB_DEFINITION } from './definitions/internal/execute-webhook';
import { EXPIRE_RECIPIENTS_SWEEP_JOB_DEFINITION } from './definitions/internal/expire-recipients-sweep';
import { PROCESS_RECIPIENT_EXPIRED_JOB_DEFINITION } from './definitions/internal/process-recipient-expired';
+import { PROCESS_SIGNING_REMINDER_JOB_DEFINITION } from './definitions/internal/process-signing-reminder';
import { SEAL_DOCUMENT_JOB_DEFINITION } from './definitions/internal/seal-document';
import { SEAL_DOCUMENT_SWEEP_JOB_DEFINITION } from './definitions/internal/seal-document-sweep';
+import { SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION } from './definitions/internal/send-signing-reminders-sweep';
import { SYNC_EMAIL_DOMAINS_JOB_DEFINITION } from './definitions/internal/sync-email-domains';
/**
@@ -43,6 +45,8 @@ export const jobsClient = new JobClient([
EXECUTE_WEBHOOK_JOB_DEFINITION,
EXPIRE_RECIPIENTS_SWEEP_JOB_DEFINITION,
PROCESS_RECIPIENT_EXPIRED_JOB_DEFINITION,
+ SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION,
+ PROCESS_SIGNING_REMINDER_JOB_DEFINITION,
CLEANUP_RATE_LIMITS_JOB_DEFINITION,
SYNC_EMAIL_DOMAINS_JOB_DEFINITION,
] as const);
diff --git a/packages/lib/jobs/definitions/emails/send-signing-email.handler.ts b/packages/lib/jobs/definitions/emails/send-signing-email.handler.ts
index 17f099afa..f02e36673 100644
--- a/packages/lib/jobs/definitions/emails/send-signing-email.handler.ts
+++ b/packages/lib/jobs/definitions/emails/send-signing-email.handler.ts
@@ -22,6 +22,7 @@ import {
RECIPIENT_ROLE_TO_EMAIL_TYPE,
} from '../../../constants/recipient-roles';
import { getEmailContext } from '../../../server-only/email/get-email-context';
+import { updateRecipientNextReminder } from '../../../server-only/recipient/update-recipient-next-reminder';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../../types/document-audit-logs';
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
import { createDocumentAuditLogData } from '../../../utils/document-audit-logs';
@@ -206,6 +207,8 @@ export const run = async ({
});
}
+ const sentAt = new Date();
+
await io.runTask('update-recipient', async () => {
await prisma.recipient.update({
where: {
@@ -213,26 +216,33 @@ export const run = async ({
},
data: {
sendStatus: SendStatus.SENT,
+ sentAt,
},
});
});
- await io.runTask('store-audit-log', async () => {
- await prisma.documentAuditLog.create({
- data: createDocumentAuditLogData({
- type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
- envelopeId: envelope.id,
- user,
- requestMetadata,
- data: {
- emailType: recipientEmailType,
- recipientId: recipient.id,
- recipientName: recipient.name,
- recipientEmail: recipient.email,
- recipientRole: recipient.role,
- isResending: false,
- },
- }),
- });
+ // Compute the first reminder time based on the envelope's effective settings.
+ await updateRecipientNextReminder({
+ recipientId: recipient.id,
+ envelopeId: envelope.id,
+ sentAt,
+ lastReminderSentAt: null,
+ });
+
+ await prisma.documentAuditLog.create({
+ data: createDocumentAuditLogData({
+ type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
+ envelopeId: envelope.id,
+ user,
+ requestMetadata,
+ data: {
+ emailType: recipientEmailType,
+ recipientId: recipient.id,
+ recipientName: recipient.name,
+ recipientEmail: recipient.email,
+ recipientRole: recipient.role,
+ isResending: false,
+ },
+ }),
});
};
diff --git a/packages/lib/jobs/definitions/internal/process-signing-reminder.handler.ts b/packages/lib/jobs/definitions/internal/process-signing-reminder.handler.ts
new file mode 100644
index 000000000..94bffad9d
--- /dev/null
+++ b/packages/lib/jobs/definitions/internal/process-signing-reminder.handler.ts
@@ -0,0 +1,222 @@
+import { createElement } from 'react';
+
+import { msg } from '@lingui/core/macro';
+import {
+ DocumentDistributionMethod,
+ DocumentStatus,
+ OrganisationType,
+ RecipientRole,
+ SendStatus,
+ SigningStatus,
+ WebhookTriggerEvents,
+} from '@prisma/client';
+
+import { mailer } from '@documenso/email/mailer';
+import DocumentReminderEmailTemplate from '@documenso/email/templates/document-reminder';
+import { prisma } from '@documenso/prisma';
+
+import { getI18nInstance } from '../../../client-only/providers/i18n-server';
+import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
+import { RECIPIENT_ROLES_DESCRIPTION } from '../../../constants/recipient-roles';
+import { getEmailContext } from '../../../server-only/email/get-email-context';
+import { updateRecipientNextReminder } from '../../../server-only/recipient/update-recipient-next-reminder';
+import { triggerWebhook } from '../../../server-only/webhooks/trigger/trigger-webhook';
+import { DOCUMENT_AUDIT_LOG_TYPE, DOCUMENT_EMAIL_TYPE } from '../../../types/document-audit-logs';
+import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
+import {
+ ZWebhookDocumentSchema,
+ mapEnvelopeToWebhookDocumentPayload,
+} from '../../../types/webhook-payload';
+import { createDocumentAuditLogData } from '../../../utils/document-audit-logs';
+import { renderCustomEmailTemplate } from '../../../utils/render-custom-email-template';
+import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
+import type { JobRunIO } from '../../client/_internal/job';
+import type { TProcessSigningReminderJobDefinition } from './process-signing-reminder';
+
+export const run = async ({
+ payload,
+ io,
+}: {
+ payload: TProcessSigningReminderJobDefinition;
+ io: JobRunIO;
+}) => {
+ const { recipientId } = payload;
+ const now = new Date();
+
+ // Atomically claim this reminder by setting lastReminderSentAt and clearing
+ // nextReminderAt so no other sweep picks it up.
+ const updatedCount = await prisma.recipient.updateMany({
+ where: {
+ id: recipientId,
+ signingStatus: SigningStatus.NOT_SIGNED,
+ sendStatus: SendStatus.SENT,
+ role: { not: RecipientRole.CC },
+ envelope: {
+ status: DocumentStatus.PENDING,
+ deletedAt: null,
+ },
+ },
+ data: {
+ lastReminderSentAt: now,
+ nextReminderAt: null,
+ },
+ });
+
+ if (updatedCount.count === 0) {
+ io.logger.info(`Recipient ${recipientId} no longer eligible for reminder, skipping`);
+ return;
+ }
+
+ const recipient = await prisma.recipient.findFirst({
+ where: { id: recipientId },
+ include: {
+ envelope: {
+ include: {
+ documentMeta: true,
+ user: true,
+ recipients: true,
+ team: {
+ select: {
+ name: true,
+ },
+ },
+ },
+ },
+ },
+ });
+
+ if (!recipient) {
+ io.logger.warn(`Recipient ${recipientId} not found`);
+ return;
+ }
+
+ const { envelope } = recipient;
+
+ if (!envelope.documentMeta) {
+ io.logger.warn(`Envelope ${envelope.id} missing documentMeta`);
+ return;
+ }
+
+ // Skip if distribution method is NONE (manual link sharing, no emails).
+ if (envelope.documentMeta.distributionMethod === DocumentDistributionMethod.NONE) {
+ io.logger.info(`Envelope ${envelope.id} uses manual distribution, skipping email reminder`);
+ return;
+ }
+
+ if (!extractDerivedDocumentEmailSettings(envelope.documentMeta).recipientSigningRequest) {
+ io.logger.info(`Envelope ${envelope.id} has email signing requests disabled, skipping`);
+ return;
+ }
+
+ const { branding, emailLanguage, organisationType, senderEmail, replyToEmail } =
+ await getEmailContext({
+ emailType: 'RECIPIENT',
+ source: {
+ type: 'team',
+ teamId: envelope.teamId,
+ },
+ meta: envelope.documentMeta,
+ });
+
+ const i18n = await getI18nInstance(emailLanguage);
+
+ const recipientActionVerb = i18n
+ ._(RECIPIENT_ROLES_DESCRIPTION[recipient.role].actionVerb)
+ .toLowerCase();
+
+ let emailSubject = i18n._(
+ msg`Reminder: Please ${recipientActionVerb} the document "${envelope.title}"`,
+ );
+
+ if (organisationType === OrganisationType.ORGANISATION) {
+ emailSubject = i18n._(
+ msg`Reminder: ${envelope.team.name} invited you to ${recipientActionVerb} a document`,
+ );
+ }
+
+ const customEmailTemplate = {
+ 'signer.name': recipient.name,
+ 'signer.email': recipient.email,
+ 'document.name': envelope.title,
+ };
+
+ if (envelope.documentMeta.subject) {
+ emailSubject = renderCustomEmailTemplate(
+ i18n._(msg`Reminder: ${envelope.documentMeta.subject}`),
+ customEmailTemplate,
+ );
+ }
+
+ const emailMessage = envelope.documentMeta.message
+ ? renderCustomEmailTemplate(envelope.documentMeta.message, customEmailTemplate)
+ : undefined;
+
+ const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
+ const signDocumentLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`;
+
+ io.logger.info(
+ `Sending signing reminder for envelope ${envelope.id} to recipient ${recipient.id} (${recipient.email})`,
+ );
+
+ const template = createElement(DocumentReminderEmailTemplate, {
+ recipientName: recipient.name,
+ documentName: envelope.title,
+ assetBaseUrl,
+ signDocumentLink,
+ customBody: emailMessage,
+ role: recipient.role,
+ });
+
+ 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: emailSubject,
+ html,
+ text,
+ });
+
+ await prisma.documentAuditLog.create({
+ data: createDocumentAuditLogData({
+ type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
+ envelopeId: envelope.id,
+ data: {
+ recipientEmail: recipient.email,
+ recipientName: recipient.name,
+ recipientId: recipient.id,
+ recipientRole: recipient.role,
+ emailType: DOCUMENT_EMAIL_TYPE.REMINDER,
+ isResending: false,
+ },
+ }),
+ });
+
+ await triggerWebhook({
+ event: WebhookTriggerEvents.DOCUMENT_REMINDER_SENT,
+ data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelope)),
+ userId: envelope.userId,
+ teamId: envelope.teamId,
+ });
+
+ // Compute the next reminder time (repeat interval).
+ if (recipient.sentAt) {
+ await updateRecipientNextReminder({
+ recipientId: recipient.id,
+ envelopeId: envelope.id,
+ sentAt: recipient.sentAt,
+ lastReminderSentAt: now,
+ });
+ }
+};
diff --git a/packages/lib/jobs/definitions/internal/process-signing-reminder.ts b/packages/lib/jobs/definitions/internal/process-signing-reminder.ts
new file mode 100644
index 000000000..aacc720a2
--- /dev/null
+++ b/packages/lib/jobs/definitions/internal/process-signing-reminder.ts
@@ -0,0 +1,31 @@
+import { z } from 'zod';
+
+import { type JobDefinition } from '../../client/_internal/job';
+
+const PROCESS_SIGNING_REMINDER_JOB_DEFINITION_ID = 'internal.process-signing-reminder';
+
+const PROCESS_SIGNING_REMINDER_JOB_DEFINITION_SCHEMA = z.object({
+ recipientId: z.number(),
+});
+
+export type TProcessSigningReminderJobDefinition = z.infer<
+ typeof PROCESS_SIGNING_REMINDER_JOB_DEFINITION_SCHEMA
+>;
+
+export const PROCESS_SIGNING_REMINDER_JOB_DEFINITION = {
+ id: PROCESS_SIGNING_REMINDER_JOB_DEFINITION_ID,
+ name: 'Process Signing Reminder',
+ version: '1.0.0',
+ trigger: {
+ name: PROCESS_SIGNING_REMINDER_JOB_DEFINITION_ID,
+ schema: PROCESS_SIGNING_REMINDER_JOB_DEFINITION_SCHEMA,
+ },
+ handler: async ({ payload, io }) => {
+ const handler = await import('./process-signing-reminder.handler');
+
+ await handler.run({ payload, io });
+ },
+} as const satisfies JobDefinition<
+ typeof PROCESS_SIGNING_REMINDER_JOB_DEFINITION_ID,
+ TProcessSigningReminderJobDefinition
+>;
diff --git a/packages/lib/jobs/definitions/internal/send-signing-reminders-sweep.handler.ts b/packages/lib/jobs/definitions/internal/send-signing-reminders-sweep.handler.ts
new file mode 100644
index 000000000..e28aa0139
--- /dev/null
+++ b/packages/lib/jobs/definitions/internal/send-signing-reminders-sweep.handler.ts
@@ -0,0 +1,49 @@
+import { DocumentStatus, RecipientRole, SendStatus, SigningStatus } from '@prisma/client';
+
+import { prisma } from '@documenso/prisma';
+
+import { jobs } from '../../client';
+import type { JobRunIO } from '../../client/_internal/job';
+import type { TSendSigningRemindersSweepJobDefinition } from './send-signing-reminders-sweep';
+
+export const run = async ({
+ io,
+}: {
+ payload: TSendSigningRemindersSweepJobDefinition;
+ io: JobRunIO;
+}) => {
+ const now = new Date();
+
+ const recipients = await prisma.recipient.findMany({
+ where: {
+ nextReminderAt: { lte: now },
+ signingStatus: SigningStatus.NOT_SIGNED,
+ sendStatus: SendStatus.SENT,
+ role: { not: RecipientRole.CC },
+ envelope: {
+ status: DocumentStatus.PENDING,
+ deletedAt: null,
+ },
+ },
+ select: { id: true },
+ take: 1000,
+ });
+
+ if (recipients.length === 0) {
+ io.logger.info('No recipients need signing reminders');
+ return;
+ }
+
+ io.logger.info(`Found ${recipients.length} recipients needing signing reminders`);
+
+ await Promise.allSettled(
+ recipients.map(async (recipient) => {
+ await jobs.triggerJob({
+ name: 'internal.process-signing-reminder',
+ payload: {
+ recipientId: recipient.id,
+ },
+ });
+ }),
+ );
+};
diff --git a/packages/lib/jobs/definitions/internal/send-signing-reminders-sweep.ts b/packages/lib/jobs/definitions/internal/send-signing-reminders-sweep.ts
new file mode 100644
index 000000000..1c12ad19f
--- /dev/null
+++ b/packages/lib/jobs/definitions/internal/send-signing-reminders-sweep.ts
@@ -0,0 +1,30 @@
+import { z } from 'zod';
+
+import { type JobDefinition } from '../../client/_internal/job';
+
+const SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION_ID = 'internal.send-signing-reminders-sweep';
+
+const SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION_SCHEMA = z.object({});
+
+export type TSendSigningRemindersSweepJobDefinition = z.infer<
+ typeof SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION_SCHEMA
+>;
+
+export const SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION = {
+ id: SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION_ID,
+ name: 'Send Signing Reminders Sweep',
+ version: '1.0.0',
+ trigger: {
+ name: SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION_ID,
+ schema: SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION_SCHEMA,
+ cron: '*/15 * * * *', // Every 15 minutes.
+ },
+ handler: async ({ payload, io }) => {
+ const handler = await import('./send-signing-reminders-sweep.handler');
+
+ await handler.run({ payload, io });
+ },
+} as const satisfies JobDefinition<
+ typeof SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION_ID,
+ TSendSigningRemindersSweepJobDefinition
+>;
diff --git a/packages/lib/server-only/document/complete-document-with-token.ts b/packages/lib/server-only/document/complete-document-with-token.ts
index e8f78a673..a9030ff4f 100644
--- a/packages/lib/server-only/document/complete-document-with-token.ts
+++ b/packages/lib/server-only/document/complete-document-with-token.ts
@@ -442,6 +442,7 @@ export const completeDocumentWithToken = async ({
where: { id: nextRecipient.id },
data: {
sendStatus: SendStatus.SENT,
+ sentAt: new Date(),
...(nextSigner && envelope.documentMeta?.allowDictateNextSigner
? {
name: nextSigner.name,
diff --git a/packages/lib/server-only/document/viewed-document.ts b/packages/lib/server-only/document/viewed-document.ts
index 5bdd1828b..23aa6d710 100644
--- a/packages/lib/server-only/document/viewed-document.ts
+++ b/packages/lib/server-only/document/viewed-document.ts
@@ -69,6 +69,8 @@ export const viewedDocument = async ({
// This handles cases where distribution is done manually
sendStatus: SendStatus.SENT,
readStatus: ReadStatus.OPENED,
+ // Only set sentAt if not already set (email may have been sent before they opened).
+ ...(!recipient.sentAt ? { sentAt: new Date() } : {}),
},
});
@@ -92,6 +94,9 @@ export const viewedDocument = async ({
});
});
+ // Don't schedule reminders for manually distributed documents —
+ // there's no email pathway to send them through.
+
const envelope = await prisma.envelope.findUniqueOrThrow({
where: {
id: recipient.envelopeId,
diff --git a/packages/lib/server-only/envelope/update-envelope.ts b/packages/lib/server-only/envelope/update-envelope.ts
index 0abbd19c9..b4e0a8d45 100644
--- a/packages/lib/server-only/envelope/update-envelope.ts
+++ b/packages/lib/server-only/envelope/update-envelope.ts
@@ -18,6 +18,7 @@ import {
import { createDocumentAuthOptions, extractDocumentAuthMethods } from '../../utils/document-auth';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { buildTeamWhereQuery, canAccessTeamDocument } from '../../utils/teams';
+import { recomputeNextReminderForEnvelope } from '../recipient/update-recipient-next-reminder';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { getEnvelopeWhereInput } from './get-envelope-by-id';
@@ -354,6 +355,11 @@ export const updateEnvelope = async ({
return result;
});
+ // Recompute reminders for active recipients when reminder settings change.
+ if (meta && 'reminderSettings' in meta) {
+ await recomputeNextReminderForEnvelope(envelope.id);
+ }
+
if (envelope.type === EnvelopeType.TEMPLATE) {
await triggerWebhook({
event: WebhookTriggerEvents.TEMPLATE_UPDATED,
diff --git a/packages/lib/server-only/recipient/update-recipient-next-reminder.ts b/packages/lib/server-only/recipient/update-recipient-next-reminder.ts
new file mode 100644
index 000000000..5530580bb
--- /dev/null
+++ b/packages/lib/server-only/recipient/update-recipient-next-reminder.ts
@@ -0,0 +1,112 @@
+import {
+ DocumentDistributionMethod,
+ RecipientRole,
+ SendStatus,
+ SigningStatus,
+} from '@prisma/client';
+
+import { prisma } from '@documenso/prisma';
+
+import {
+ ZEnvelopeReminderSettings,
+ resolveNextReminderAt,
+} from '../../constants/envelope-reminder';
+
+/**
+ * Compute and store `nextReminderAt` for a single recipient.
+ *
+ * Call this after:
+ * - Sending the signing email (sentAt is set)
+ * - Sending a reminder (lastReminderSentAt is updated)
+ *
+ * If `reminderSettings` is provided it's used directly, avoiding a query.
+ * Otherwise it's read from the envelope's documentMeta (already resolved
+ * from the org/team cascade at envelope creation time).
+ */
+export const updateRecipientNextReminder = async (options: {
+ recipientId: number;
+ envelopeId: string;
+ sentAt: Date;
+ lastReminderSentAt: Date | null;
+ reminderSettings?: ReturnType | null;
+}) => {
+ const { recipientId, envelopeId, sentAt, lastReminderSentAt } = options;
+
+ let settings = options.reminderSettings;
+
+ if (settings === undefined) {
+ const envelope = await prisma.envelope.findFirst({
+ where: { id: envelopeId },
+ select: { documentMeta: { select: { reminderSettings: true } } },
+ });
+
+ settings = envelope?.documentMeta?.reminderSettings
+ ? ZEnvelopeReminderSettings.parse(envelope.documentMeta.reminderSettings)
+ : null;
+ }
+
+ const nextReminderAt = resolveNextReminderAt({
+ config: settings,
+ sentAt,
+ lastReminderSentAt,
+ });
+
+ await prisma.recipient.update({
+ where: { id: recipientId },
+ data: { nextReminderAt },
+ });
+};
+
+/**
+ * Recompute `nextReminderAt` for all active (unsigned, sent) recipients
+ * of a given envelope. Call when document-level reminder settings change.
+ */
+export const recomputeNextReminderForEnvelope = async (envelopeId: string) => {
+ const envelope = await prisma.envelope.findFirst({
+ where: { id: envelopeId },
+ select: {
+ documentMeta: {
+ select: { reminderSettings: true, distributionMethod: true },
+ },
+ },
+ });
+
+ // No reminders for manually distributed documents.
+ const isEmailDistribution =
+ envelope?.documentMeta?.distributionMethod !== DocumentDistributionMethod.NONE;
+
+ const settings =
+ isEmailDistribution && envelope?.documentMeta?.reminderSettings
+ ? ZEnvelopeReminderSettings.parse(envelope.documentMeta.reminderSettings)
+ : null;
+
+ const recipients = await prisma.recipient.findMany({
+ where: {
+ envelopeId,
+ signingStatus: SigningStatus.NOT_SIGNED,
+ sendStatus: SendStatus.SENT,
+ sentAt: { not: null },
+ role: { not: RecipientRole.CC },
+ },
+ select: { id: true, sentAt: true, lastReminderSentAt: true },
+ });
+
+ await Promise.all(
+ recipients.map(async (recipient) => {
+ if (!recipient.sentAt) {
+ return;
+ }
+
+ const nextReminderAt = resolveNextReminderAt({
+ config: settings,
+ sentAt: recipient.sentAt,
+ lastReminderSentAt: recipient.lastReminderSentAt,
+ });
+
+ await prisma.recipient.update({
+ where: { id: recipient.id },
+ data: { nextReminderAt },
+ });
+ }),
+ );
+};
diff --git a/packages/lib/server-only/template/create-document-from-direct-template.ts b/packages/lib/server-only/template/create-document-from-direct-template.ts
index 59b1ec910..0cb2cd2e9 100644
--- a/packages/lib/server-only/template/create-document-from-direct-template.ts
+++ b/packages/lib/server-only/template/create-document-from-direct-template.ts
@@ -705,6 +705,7 @@ export const createDocumentFromDirectTemplate = async ({
where: { id: nextRecipient.id },
data: {
sendStatus: SendStatus.SENT,
+ sentAt: new Date(),
...(nextSigner && documentMeta?.allowDictateNextSigner
? {
name: nextSigner.name,
diff --git a/packages/lib/types/document-audit-logs.ts b/packages/lib/types/document-audit-logs.ts
index 5a1f21a83..eafa1723f 100644
--- a/packages/lib/types/document-audit-logs.ts
+++ b/packages/lib/types/document-audit-logs.ts
@@ -63,6 +63,7 @@ export const ZDocumentAuditLogEmailTypeSchema = z.enum([
'ASSISTING_REQUEST',
'CC',
'DOCUMENT_COMPLETED',
+ 'REMINDER',
]);
export const ZDocumentMetaDiffTypeSchema = z.enum([
diff --git a/packages/lib/types/document-meta.ts b/packages/lib/types/document-meta.ts
index f3ad49def..759a6fdc8 100644
--- a/packages/lib/types/document-meta.ts
+++ b/packages/lib/types/document-meta.ts
@@ -4,6 +4,7 @@ import { z } from 'zod';
import { VALID_DATE_FORMAT_VALUES } from '@documenso/lib/constants/date-formats';
import { ZEnvelopeExpirationPeriod } from '@documenso/lib/constants/envelope-expiration';
+import { ZEnvelopeReminderSettings } from '@documenso/lib/constants/envelope-reminder';
import { SUPPORTED_LANGUAGE_CODES } from '@documenso/lib/constants/i18n';
import { isValidRedirectUrl } from '@documenso/lib/utils/is-valid-redirect-url';
import { zEmail } from '@documenso/lib/utils/zod';
@@ -131,6 +132,7 @@ export const ZDocumentMetaCreateSchema = z.object({
emailReplyTo: zEmail().nullish(),
emailSettings: ZDocumentEmailSettingsSchema.nullish(),
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullish(),
+ reminderSettings: ZEnvelopeReminderSettings.nullish(),
});
export type TDocumentMetaCreate = z.infer;
diff --git a/packages/lib/types/document.ts b/packages/lib/types/document.ts
index 125cae519..56bc75568 100644
--- a/packages/lib/types/document.ts
+++ b/packages/lib/types/document.ts
@@ -72,6 +72,7 @@ export const ZDocumentSchema = LegacyDocumentSchema.pick({
emailId: true,
emailReplyTo: true,
envelopeExpirationPeriod: true,
+ reminderSettings: true,
}).extend({
password: z.string().nullable().default(null),
documentId: z.number().default(-1).optional(),
diff --git a/packages/lib/types/envelope-editor.ts b/packages/lib/types/envelope-editor.ts
index 02cfa4abd..a8b8787e5 100644
--- a/packages/lib/types/envelope-editor.ts
+++ b/packages/lib/types/envelope-editor.ts
@@ -43,6 +43,7 @@ export const ZEnvelopeEditorSettingsSchema = z.object({
allowConfigureRedirectUrl: z.boolean(),
allowConfigureDistribution: z.boolean(),
allowConfigureExpirationPeriod: z.boolean(),
+ allowConfigureReminders: z.boolean(),
allowConfigureEmailSender: z.boolean(),
allowConfigureEmailReplyTo: z.boolean(),
})
@@ -122,6 +123,7 @@ export const DEFAULT_EDITOR_CONFIG: EnvelopeEditorConfig = {
allowConfigureRedirectUrl: true,
allowConfigureDistribution: true,
allowConfigureExpirationPeriod: true,
+ allowConfigureReminders: true,
allowConfigureEmailSender: true,
allowConfigureEmailReplyTo: true,
},
@@ -180,6 +182,7 @@ export const DEFAULT_EMBEDDED_EDITOR_CONFIG = {
allowConfigureRedirectUrl: true,
allowConfigureDistribution: true,
allowConfigureExpirationPeriod: true,
+ allowConfigureReminders: true,
allowConfigureEmailSender: true,
allowConfigureEmailReplyTo: true,
},
@@ -271,6 +274,7 @@ export const ZEditorEnvelopeSchema = EnvelopeSchema.pick({
emailId: true,
emailReplyTo: true,
envelopeExpirationPeriod: true,
+ reminderSettings: true,
}),
recipients: ZEnvelopeRecipientLiteSchema.array(),
fields: ZEnvelopeFieldSchema.array(),
diff --git a/packages/lib/types/recipient.ts b/packages/lib/types/recipient.ts
index 93ce73f02..a15233761 100644
--- a/packages/lib/types/recipient.ts
+++ b/packages/lib/types/recipient.ts
@@ -118,6 +118,13 @@ export const ZEnvelopeRecipientManySchema = ZRecipientManySchema.omit({
templateId: true,
});
+export type TRecipientSchema = z.infer;
+export type TRecipientLite = z.infer;
+export type TRecipientMany = z.infer;
+export type TEnvelopeRecipientSchema = z.infer;
+export type TEnvelopeRecipientLite = z.infer;
+export type TEnvelopeRecipientMany = z.infer;
+
export const ZRecipientEmailSchema = z.union([
z.literal(''),
zEmail('Invalid email').trim().toLowerCase().max(254),
diff --git a/packages/lib/utils/document.ts b/packages/lib/utils/document.ts
index 58753a6c8..eaf63d268 100644
--- a/packages/lib/utils/document.ts
+++ b/packages/lib/utils/document.ts
@@ -66,6 +66,9 @@ export const extractDerivedDocumentMeta = (
// Envelope expiration.
envelopeExpirationPeriod:
meta.envelopeExpirationPeriod ?? settings.envelopeExpirationPeriod ?? null,
+
+ // Reminder settings.
+ reminderSettings: meta.reminderSettings ?? settings.reminderSettings ?? null,
} satisfies Omit;
};
diff --git a/packages/lib/utils/embed-config.ts b/packages/lib/utils/embed-config.ts
index d3f44b72e..5456d471e 100644
--- a/packages/lib/utils/embed-config.ts
+++ b/packages/lib/utils/embed-config.ts
@@ -67,6 +67,9 @@ export const buildEmbeddedFeatures = (
allowConfigureExpirationPeriod:
features.settings?.allowConfigureExpirationPeriod ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureExpirationPeriod,
+ allowConfigureReminders:
+ features.settings?.allowConfigureReminders ??
+ DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureReminders,
allowConfigureEmailSender:
features.settings?.allowConfigureEmailSender ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureEmailSender,
diff --git a/packages/lib/utils/envelope.ts b/packages/lib/utils/envelope.ts
index 090e77f6f..0f0aba1c3 100644
--- a/packages/lib/utils/envelope.ts
+++ b/packages/lib/utils/envelope.ts
@@ -252,7 +252,7 @@ export type EnvelopeItemPermissions = {
export const getEnvelopeItemPermissions = (
envelope: Pick,
- recipients: Recipient[],
+ recipients: Pick[],
): EnvelopeItemPermissions => {
// Always reject completed/rejected/deleted envelopes.
if (
diff --git a/packages/lib/utils/organisations.ts b/packages/lib/utils/organisations.ts
index b38e9a697..e4eb814f3 100644
--- a/packages/lib/utils/organisations.ts
+++ b/packages/lib/utils/organisations.ts
@@ -9,6 +9,7 @@ import type { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/orga
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../constants/date-formats';
import { DEFAULT_ENVELOPE_EXPIRATION_PERIOD } from '../constants/envelope-expiration';
+import { DEFAULT_ENVELOPE_REMINDER_SETTINGS } from '../constants/envelope-reminder';
import {
LOWEST_ORGANISATION_ROLE,
ORGANISATION_MEMBER_ROLE_HIERARCHY,
@@ -142,6 +143,8 @@ export const generateDefaultOrganisationSettings = (): Omit<
envelopeExpirationPeriod: DEFAULT_ENVELOPE_EXPIRATION_PERIOD,
+ reminderSettings: DEFAULT_ENVELOPE_REMINDER_SETTINGS,
+
aiFeaturesEnabled: false,
};
};
diff --git a/packages/lib/utils/recipients.ts b/packages/lib/utils/recipients.ts
index a8a4c574f..ce7314f42 100644
--- a/packages/lib/utils/recipients.ts
+++ b/packages/lib/utils/recipients.ts
@@ -1,10 +1,11 @@
import type { Envelope } from '@prisma/client';
-import { type Field, type Recipient, RecipientRole, SigningStatus } from '@prisma/client';
+import { type Field, RecipientRole, SigningStatus } from '@prisma/client';
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
import { AppError, AppErrorCode } from '../errors/app-error';
+import type { TRecipientLite } from '../types/recipient';
import { extractLegacyIds } from '../universal/id';
import { zEmail } from './zod';
@@ -20,7 +21,7 @@ export const RECIPIENT_ROLES_THAT_REQUIRE_FIELDS = [RecipientRole.SIGNER] as con
*
* Currently only SIGNERs are validated - they must have at least one signature field.
*/
-export const getRecipientsWithMissingFields = >(
+export const getRecipientsWithMissingFields = >(
recipients: T[],
fields: Pick[],
): T[] => {
@@ -42,7 +43,10 @@ export const formatSigningLink = (token: string) => `${NEXT_PUBLIC_WEBAPP_URL()}
/**
* Whether a recipient can be modified by the document owner.
*/
-export const canRecipientBeModified = (recipient: Recipient, fields: Field[]) => {
+export const canRecipientBeModified = (
+ recipient: TRecipientLite,
+ fields: Pick[],
+) => {
if (!recipient) {
return false;
}
@@ -72,7 +76,10 @@ export const canRecipientBeModified = (recipient: Recipient, fields: Field[]) =>
* - They are not a Viewer or CCer
* - They can be modified (canRecipientBeModified)
*/
-export const canRecipientFieldsBeModified = (recipient: Recipient, fields: Field[]) => {
+export const canRecipientFieldsBeModified = (
+ recipient: TRecipientLite,
+ fields: Pick[],
+) => {
if (!canRecipientBeModified(recipient, fields)) {
return false;
}
@@ -81,7 +88,7 @@ export const canRecipientFieldsBeModified = (recipient: Recipient, fields: Field
};
export const mapRecipientToLegacyRecipient = (
- recipient: Recipient,
+ recipient: TRecipientLite,
envelope: Pick,
) => {
const legacyId = extractLegacyIds(envelope);
@@ -92,6 +99,7 @@ export const mapRecipientToLegacyRecipient = (
};
};
+
export const findRecipientByEmail = ({
recipients,
userEmail,
@@ -102,7 +110,7 @@ export const findRecipientByEmail = ({
teamEmail?: string | null;
}) => recipients.find((r) => r.email === userEmail || (teamEmail && r.email === teamEmail));
-export const isRecipientEmailValidForSending = (recipient: Pick) => {
+export const isRecipientEmailValidForSending = (recipient: Pick) => {
return zEmail().safeParse(recipient.email).success;
};
diff --git a/packages/lib/utils/teams.ts b/packages/lib/utils/teams.ts
index 02fc562c1..ba71da07a 100644
--- a/packages/lib/utils/teams.ts
+++ b/packages/lib/utils/teams.ts
@@ -208,6 +208,8 @@ export const generateDefaultTeamSettings = (): Omit {
diff --git a/packages/trpc/server/organisation-router/update-organisation-settings.ts b/packages/trpc/server/organisation-router/update-organisation-settings.ts
index 7dfa9b033..570187cf8 100644
--- a/packages/trpc/server/organisation-router/update-organisation-settings.ts
+++ b/packages/trpc/server/organisation-router/update-organisation-settings.ts
@@ -39,6 +39,7 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
defaultRecipients,
delegateDocumentOwnership,
envelopeExpirationPeriod,
+ reminderSettings,
// Branding related settings.
brandingEnabled,
@@ -151,6 +152,7 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
delegateDocumentOwnership: derivedDelegateDocumentOwnership,
envelopeExpirationPeriod:
envelopeExpirationPeriod === null ? Prisma.DbNull : envelopeExpirationPeriod,
+ reminderSettings: reminderSettings === null ? Prisma.DbNull : reminderSettings,
// Branding related settings.
brandingEnabled,
diff --git a/packages/trpc/server/organisation-router/update-organisation-settings.types.ts b/packages/trpc/server/organisation-router/update-organisation-settings.types.ts
index bc211a93c..daf93d821 100644
--- a/packages/trpc/server/organisation-router/update-organisation-settings.types.ts
+++ b/packages/trpc/server/organisation-router/update-organisation-settings.types.ts
@@ -1,6 +1,7 @@
import { z } from 'zod';
import { ZEnvelopeExpirationPeriod } from '@documenso/lib/constants/envelope-expiration';
+import { ZEnvelopeReminderSettings } from '@documenso/lib/constants/envelope-reminder';
import { SUPPORTED_LANGUAGE_CODES } from '@documenso/lib/constants/i18n';
import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
@@ -28,6 +29,7 @@ export const ZUpdateOrganisationSettingsRequestSchema = z.object({
defaultRecipients: ZDefaultRecipientsSchema.nullish(),
delegateDocumentOwnership: z.boolean().nullish(),
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.optional(),
+ reminderSettings: ZEnvelopeReminderSettings.optional(),
// Branding related settings.
brandingEnabled: z.boolean().optional(),
diff --git a/packages/trpc/server/team-router/update-team-settings.ts b/packages/trpc/server/team-router/update-team-settings.ts
index f764bc95d..b44db128a 100644
--- a/packages/trpc/server/team-router/update-team-settings.ts
+++ b/packages/trpc/server/team-router/update-team-settings.ts
@@ -41,6 +41,7 @@ export const updateTeamSettingsRoute = authenticatedProcedure
drawSignatureEnabled,
delegateDocumentOwnership,
envelopeExpirationPeriod,
+ reminderSettings,
// Branding related settings.
brandingEnabled,
@@ -157,6 +158,7 @@ export const updateTeamSettingsRoute = authenticatedProcedure
delegateDocumentOwnership,
envelopeExpirationPeriod:
envelopeExpirationPeriod === null ? Prisma.DbNull : envelopeExpirationPeriod,
+ reminderSettings: reminderSettings === null ? Prisma.DbNull : reminderSettings,
// Branding related settings.
brandingEnabled,
diff --git a/packages/trpc/server/team-router/update-team-settings.types.ts b/packages/trpc/server/team-router/update-team-settings.types.ts
index 0ba4bebed..e8f24e611 100644
--- a/packages/trpc/server/team-router/update-team-settings.types.ts
+++ b/packages/trpc/server/team-router/update-team-settings.types.ts
@@ -1,6 +1,7 @@
import { z } from 'zod';
import { ZEnvelopeExpirationPeriod } from '@documenso/lib/constants/envelope-expiration';
+import { ZEnvelopeReminderSettings } from '@documenso/lib/constants/envelope-reminder';
import { SUPPORTED_LANGUAGE_CODES } from '@documenso/lib/constants/i18n';
import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
@@ -31,6 +32,7 @@ export const ZUpdateTeamSettingsRequestSchema = z.object({
drawSignatureEnabled: z.boolean().nullish(),
delegateDocumentOwnership: z.boolean().nullish(),
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullish(),
+ reminderSettings: ZEnvelopeReminderSettings.nullish(),
// Branding related settings.
brandingEnabled: z.boolean().nullish(),
diff --git a/packages/ui/components/document/document-read-only-fields.tsx b/packages/ui/components/document/document-read-only-fields.tsx
index 6fb855a2b..818d3ea25 100644
--- a/packages/ui/components/document/document-read-only-fields.tsx
+++ b/packages/ui/components/document/document-read-only-fields.tsx
@@ -2,12 +2,13 @@ import { useState } from 'react';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
-import type { DocumentMeta, Field, Recipient } from '@prisma/client';
+import type { DocumentMeta, Field } from '@prisma/client';
import { SigningStatus } from '@prisma/client';
import { Clock, EyeOffIcon } from 'lucide-react';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { isTemplateRecipientEmailPlaceholder } from '@documenso/lib/constants/template';
+import type { TRecipientLite } from '@documenso/lib/types/recipient';
import { parseMessageDescriptor } from '@documenso/lib/utils/i18n';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { FieldRootContainer } from '@documenso/ui/components/field/field';
@@ -34,7 +35,7 @@ const getRecipientDisplayText = (recipient: { name: string; email: string }) =>
};
export type DocumentField = Field & {
- recipient: Pick;
+ recipient: Pick;
};
export type DocumentReadOnlyFieldsProps = {
@@ -68,7 +69,7 @@ export type DocumentReadOnlyFieldsProps = {
export const mapFieldsWithRecipients = (
fields: Field[],
- recipients: Recipient[],
+ recipients: TRecipientLite[],
): DocumentField[] => {
return fields.map((field) => {
const recipient = recipients.find((recipient) => recipient.id === field.recipientId) || {
diff --git a/packages/ui/components/document/expiration-period-picker.tsx b/packages/ui/components/document/expiration-period-picker.tsx
index ee1c39263..1d2347560 100644
--- a/packages/ui/components/document/expiration-period-picker.tsx
+++ b/packages/ui/components/document/expiration-period-picker.tsx
@@ -90,7 +90,7 @@ export const ExpirationPeriodPicker = ({
return (
-
+
@@ -113,13 +113,14 @@ export const ExpirationPeriodPicker = ({
type="number"
min={1}
className="w-20 bg-background"
+ data-testid="envelope-expiration-amount"
value={amount}
onChange={(e) => onAmountChange(Number(e.target.value))}
disabled={disabled}
/>
-
+
diff --git a/packages/ui/components/document/reminder-settings-picker.tsx b/packages/ui/components/document/reminder-settings-picker.tsx
new file mode 100644
index 000000000..3c2b70779
--- /dev/null
+++ b/packages/ui/components/document/reminder-settings-picker.tsx
@@ -0,0 +1,266 @@
+import { Plural, Trans } from '@lingui/react/macro';
+
+import type {
+ TEnvelopeReminderDurationPeriod,
+ TEnvelopeReminderPeriod,
+ TEnvelopeReminderSettings,
+} from '@documenso/lib/constants/envelope-reminder';
+import { Input } from '@documenso/ui/primitives/input';
+import { Label } from '@documenso/ui/primitives/label';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@documenso/ui/primitives/select';
+
+type ReminderMode = 'enabled' | 'disabled' | 'inherit';
+
+const getMode = (value: TEnvelopeReminderSettings | null | undefined): ReminderMode => {
+ if (value === null || value === undefined) {
+ return 'inherit';
+ }
+
+ if ('disabled' in value.sendAfter) {
+ return 'disabled';
+ }
+
+ return 'enabled';
+};
+
+const getPeriodAmount = (period: TEnvelopeReminderPeriod | undefined): number => {
+ if (period && 'amount' in period) {
+ return period.amount;
+ }
+
+ return 1;
+};
+
+const getPeriodUnit = (
+ period: TEnvelopeReminderPeriod | undefined,
+): TEnvelopeReminderDurationPeriod['unit'] => {
+ if (period && 'unit' in period) {
+ return period.unit;
+ }
+
+ return 'day';
+};
+
+export type ReminderSettingsPickerProps = {
+ value: TEnvelopeReminderSettings | null | undefined;
+ onChange: (value: TEnvelopeReminderSettings | null) => void;
+ disabled?: boolean;
+ inheritLabel?: string;
+};
+
+export const ReminderSettingsPicker = ({
+ value,
+ onChange,
+ disabled = false,
+ inheritLabel,
+}: ReminderSettingsPickerProps) => {
+ const mode = getMode(value);
+
+ const sendAfterAmount = getPeriodAmount(value?.sendAfter);
+ const sendAfterUnit = getPeriodUnit(value?.sendAfter);
+ const repeatEveryAmount = getPeriodAmount(value?.repeatEvery);
+ const repeatEveryUnit = getPeriodUnit(value?.repeatEvery);
+
+ const onModeChange = (newMode: string) => {
+ if (newMode === 'inherit') {
+ onChange(null);
+ return;
+ }
+
+ if (newMode === 'disabled') {
+ onChange({
+ sendAfter: { disabled: true },
+ repeatEvery: { disabled: true },
+ });
+ return;
+ }
+
+ onChange({
+ sendAfter: { unit: sendAfterUnit, amount: sendAfterAmount },
+ repeatEvery: { unit: repeatEveryUnit, amount: repeatEveryAmount },
+ });
+ };
+
+ const updateSendAfter = (
+ updates: Partial<{ amount: number; unit: TEnvelopeReminderDurationPeriod['unit'] }>,
+ ) => {
+ const newAmount = Math.max(1, Math.floor(updates.amount ?? sendAfterAmount));
+ const newUnit = updates.unit ?? sendAfterUnit;
+
+ onChange({
+ sendAfter: { unit: newUnit, amount: newAmount },
+ repeatEvery: value?.repeatEvery ?? { unit: repeatEveryUnit, amount: repeatEveryAmount },
+ });
+ };
+
+ const updateRepeatEvery = (
+ updates: Partial<{ amount: number; unit: TEnvelopeReminderDurationPeriod['unit'] }>,
+ ) => {
+ const newAmount = Math.max(1, Math.floor(updates.amount ?? repeatEveryAmount));
+ const newUnit = updates.unit ?? repeatEveryUnit;
+
+ onChange({
+ sendAfter: value?.sendAfter ?? { unit: sendAfterUnit, amount: sendAfterAmount },
+ repeatEvery: { unit: newUnit, amount: newAmount },
+ });
+ };
+
+ const onRepeatModeChange = (newMode: string) => {
+ if (newMode === 'disabled') {
+ onChange({
+ sendAfter: value?.sendAfter ?? { unit: sendAfterUnit, amount: sendAfterAmount },
+ repeatEvery: { disabled: true },
+ });
+ return;
+ }
+
+ onChange({
+ sendAfter: value?.sendAfter ?? { unit: sendAfterUnit, amount: sendAfterAmount },
+ repeatEvery: { unit: repeatEveryUnit, amount: repeatEveryAmount },
+ });
+ };
+
+ const repeatMode = value?.repeatEvery && 'disabled' in value.repeatEvery ? 'disabled' : 'enabled';
+
+ return (
+
+
+
+
+
+
+
+
+ Enabled
+
+
+
+ No reminders
+
+
+ {inheritLabel !== undefined && {inheritLabel} }
+
+
+
+ {mode === 'enabled' && (
+
+
+
+ Send first reminder after
+
+
+
+ updateSendAfter({ amount: Number(e.target.value) })}
+ disabled={disabled}
+ data-testid="reminder-send-after-amount"
+ />
+
+
+ updateSendAfter({
+ unit: unit as TEnvelopeReminderDurationPeriod['unit'],
+ })
+ }
+ disabled={disabled}
+ testId="reminder-send-after-unit"
+ />
+
+
+
+
+
+ Then repeat every
+
+
+
+
+
+
+
+
+
+ Custom interval
+
+
+
+ Don't repeat
+
+
+
+
+ {repeatMode === 'enabled' && (
+
+ updateRepeatEvery({ amount: Number(e.target.value) })}
+ disabled={disabled}
+ data-testid="reminder-repeat-amount"
+ />
+
+
+ updateRepeatEvery({
+ unit: unit as TEnvelopeReminderDurationPeriod['unit'],
+ })
+ }
+ disabled={disabled}
+ testId="reminder-repeat-unit"
+ />
+
+ )}
+
+
+ )}
+
+ );
+};
+
+const UnitSelect = ({
+ value,
+ amount,
+ onChange,
+ disabled,
+ testId,
+}: {
+ value: string;
+ amount: number;
+ onChange: (value: string) => void;
+ disabled: boolean;
+ testId: string;
+}) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+);
diff --git a/packages/ui/primitives/document-flow/add-fields.tsx b/packages/ui/primitives/document-flow/add-fields.tsx
index 2ed711ac2..a8e67917e 100644
--- a/packages/ui/primitives/document-flow/add-fields.tsx
+++ b/packages/ui/primitives/document-flow/add-fields.tsx
@@ -4,7 +4,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
-import type { Field, Recipient } from '@prisma/client';
+import type { Field } from '@prisma/client';
import { FieldType, Prisma, RecipientRole, SendStatus } from '@prisma/client';
import {
CalendarDays,
@@ -28,6 +28,7 @@ import {
type TFieldMetaSchema as FieldMeta,
ZFieldMetaSchema,
} from '@documenso/lib/types/field-meta';
+import type { TRecipientLite } from '@documenso/lib/types/recipient';
import { nanoid } from '@documenso/lib/universal/id';
import { ADVANCED_FIELD_TYPES_WITH_OPTIONAL_SETTING } from '@documenso/lib/utils/advanced-fields-helpers';
import { validateFieldsUninserted } from '@documenso/lib/utils/fields';
@@ -83,7 +84,7 @@ export type FieldFormType = {
export type AddFieldsFormProps = {
documentFlow: DocumentFlowStep;
hideRecipients?: boolean;
- recipients: Recipient[];
+ recipients: TRecipientLite[];
fields: Field[];
onSubmit: (_data: TAddFieldsFormSchema) => void;
onAutoSave: (_data: TAddFieldsFormSchema) => Promise;
@@ -172,7 +173,7 @@ export const AddFieldsFormPartial = ({
});
const [selectedField, setSelectedField] = useState(null);
- const [selectedSigner, setSelectedSigner] = useState(null);
+ const [selectedSigner, setSelectedSigner] = useState(null);
const [lastActiveField, setLastActiveField] = useState(
null,
);
@@ -536,7 +537,7 @@ export const AddFieldsFormPartial = ({
}, [recipients]);
const recipientsByRole = useMemo(() => {
- const recipientsByRole: Record = {
+ const recipientsByRole: Record = {
CC: [],
VIEWER: [],
SIGNER: [],
diff --git a/packages/ui/primitives/document-flow/add-settings.tsx b/packages/ui/primitives/document-flow/add-settings.tsx
index 8ecfb786b..a2b748ad1 100644
--- a/packages/ui/primitives/document-flow/add-settings.tsx
+++ b/packages/ui/primitives/document-flow/add-settings.tsx
@@ -6,7 +6,6 @@ import {
DocumentStatus,
DocumentVisibility,
type Field,
- type Recipient,
SendStatus,
TeamMemberRole,
} from '@prisma/client';
@@ -21,6 +20,7 @@ import { DOCUMENT_SIGNATURE_TYPES } from '@documenso/lib/constants/document';
import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
import type { TDocument } from '@documenso/lib/types/document';
+import type { TRecipientLite } from '@documenso/lib/types/recipient';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams';
import {
@@ -74,7 +74,7 @@ import type { DocumentFlowStep } from './types';
export type AddSettingsFormProps = {
documentFlow: DocumentFlowStep;
- recipients: Recipient[];
+ recipients: TRecipientLite[];
fields: Field[];
isDocumentPdfLoaded: boolean;
document: TDocument;
diff --git a/packages/ui/primitives/document-flow/add-signers.tsx b/packages/ui/primitives/document-flow/add-signers.tsx
index e302401da..c66fb668a 100644
--- a/packages/ui/primitives/document-flow/add-signers.tsx
+++ b/packages/ui/primitives/document-flow/add-signers.tsx
@@ -6,7 +6,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
-import type { Field, Recipient } from '@prisma/client';
+import type { Field } from '@prisma/client';
import { DocumentSigningOrder, RecipientRole, SendStatus } from '@prisma/client';
import { motion } from 'framer-motion';
import { GripVerticalIcon, HelpCircle, Plus, Trash } from 'lucide-react';
@@ -19,6 +19,7 @@ import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounce
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
+import type { TRecipientLite } 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';
@@ -54,12 +55,12 @@ import { SigningOrderConfirmation } from './signing-order-confirmation';
import type { DocumentFlowStep } from './types';
type AutoSaveResponse = {
- recipients: Recipient[];
+ recipients: TRecipientLite[];
};
export type AddSignersFormProps = {
documentFlow: DocumentFlowStep;
- recipients: Recipient[];
+ recipients: TRecipientLite[];
fields: Field[];
signingOrder?: DocumentSigningOrder | null;
allowDictateNextSigner?: boolean;
diff --git a/packages/ui/primitives/document-flow/add-subject.tsx b/packages/ui/primitives/document-flow/add-subject.tsx
index 4afccd5f1..59b09d1f3 100644
--- a/packages/ui/primitives/document-flow/add-subject.tsx
+++ b/packages/ui/primitives/document-flow/add-subject.tsx
@@ -4,7 +4,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
-import type { Field, Recipient } from '@prisma/client';
+import type { Field } from '@prisma/client';
import { DocumentDistributionMethod, DocumentStatus, RecipientRole } from '@prisma/client';
import { AnimatePresence, motion } from 'framer-motion';
import { InfoIcon } from 'lucide-react';
@@ -15,6 +15,7 @@ import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/org
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import type { TDocument } from '@documenso/lib/types/document';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
+import type { TRecipientLite } from '@documenso/lib/types/recipient';
import { formatSigningLink } from '@documenso/lib/utils/recipients';
import { trpc } from '@documenso/trpc/react';
import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper';
@@ -59,7 +60,7 @@ import type { DocumentFlowStep } from './types';
export type AddSubjectFormProps = {
documentFlow: DocumentFlowStep;
- recipients: Recipient[];
+ recipients: TRecipientLite[];
fields: Field[];
document: TDocument;
onSubmit: (_data: TAddSubjectFormSchema) => void;
diff --git a/packages/ui/primitives/recipient-selector.tsx b/packages/ui/primitives/recipient-selector.tsx
index 2451a0f8d..1832e3764 100644
--- a/packages/ui/primitives/recipient-selector.tsx
+++ b/packages/ui/primitives/recipient-selector.tsx
@@ -2,12 +2,12 @@ import { useCallback, useMemo, useState } from 'react';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
-import type { Recipient } from '@prisma/client';
import { RecipientRole, SendStatus, SigningStatus } from '@prisma/client';
import { Check, ChevronsUpDown, Info } from 'lucide-react';
import { sortBy } from 'remeda';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
+import type { TRecipientLite } from '@documenso/lib/types/recipient';
import { getRecipientColorStyles } from '../lib/recipient-colors';
import { cn } from '../lib/utils';
@@ -18,9 +18,9 @@ import { Tooltip, TooltipContent, TooltipTrigger } from './tooltip';
export interface RecipientSelectorProps {
className?: string;
- selectedRecipient: Recipient | null;
- onSelectedRecipientChange: (recipient: Recipient) => void;
- recipients: Recipient[];
+ selectedRecipient: TRecipientLite | null;
+ onSelectedRecipientChange: (recipient: TRecipientLite) => void;
+ recipients: TRecipientLite[];
align?: 'center' | 'end' | 'start';
}
@@ -35,7 +35,7 @@ export const RecipientSelector = ({
const [showRecipientsSelector, setShowRecipientsSelector] = useState(false);
const recipientsByRole = useMemo(() => {
- const recipientsWithRole: Record = {
+ const recipientsWithRole: Record = {
CC: [],
VIEWER: [],
SIGNER: [],
@@ -67,12 +67,12 @@ export const RecipientSelector = ({
[(r) => r.signingOrder || Number.MAX_SAFE_INTEGER, 'asc'],
[(r) => r.id, 'asc'],
),
- ] as [RecipientRole, Recipient[]],
+ ] as [RecipientRole, TRecipientLite[]],
);
}, [recipientsByRole]);
const getRecipientLabel = useCallback(
- (recipient: Recipient) => {
+ (recipient: TRecipientLite) => {
if (recipient.name && recipient.email) {
return `${recipient.name} (${recipient.email})`;
}
diff --git a/packages/ui/primitives/template-flow/add-template-fields.tsx b/packages/ui/primitives/template-flow/add-template-fields.tsx
index 116387d38..a662abb88 100644
--- a/packages/ui/primitives/template-flow/add-template-fields.tsx
+++ b/packages/ui/primitives/template-flow/add-template-fields.tsx
@@ -4,7 +4,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
-import type { Field, Recipient } from '@prisma/client';
+import type { Field } from '@prisma/client';
import { FieldType, RecipientRole, SendStatus } from '@prisma/client';
import {
CalendarDays,
@@ -31,6 +31,7 @@ import {
type TFieldMetaSchema as FieldMeta,
ZFieldMetaSchema,
} from '@documenso/lib/types/field-meta';
+import type { TRecipientLite } from '@documenso/lib/types/recipient';
import { nanoid } from '@documenso/lib/universal/id';
import { ADVANCED_FIELD_TYPES_WITH_OPTIONAL_SETTING } from '@documenso/lib/utils/advanced-fields-helpers';
import { parseMessageDescriptor } from '@documenso/lib/utils/i18n';
@@ -75,7 +76,7 @@ const DEFAULT_WIDTH_PX = MIN_WIDTH_PX * 2.5;
export type AddTemplateFieldsFormProps = {
documentFlow: DocumentFlowStep;
- recipients: Recipient[];
+ recipients: TRecipientLite[];
fields: Field[];
onSubmit: (_data: TAddTemplateFieldsFormSchema) => void;
onAutoSave: (_data: TAddTemplateFieldsFormSchema) => Promise;
@@ -154,7 +155,7 @@ export const AddTemplateFieldsFormPartial = ({
});
const [selectedField, setSelectedField] = useState(null);
- const [selectedSigner, setSelectedSigner] = useState(null);
+ const [selectedSigner, setSelectedSigner] = useState(null);
const [showRecipientsSelector, setShowRecipientsSelector] = useState(false);
const selectedSignerIndex = recipients.findIndex((r) => r.id === selectedSigner?.id);
@@ -491,7 +492,7 @@ export const AddTemplateFieldsFormPartial = ({
}, [recipients]);
const recipientsByRole = useMemo(() => {
- const recipientsByRole: Record = {
+ const recipientsByRole: Record = {
CC: [],
VIEWER: [],
SIGNER: [],
@@ -520,7 +521,7 @@ export const AddTemplateFieldsFormPartial = ({
const recipientsByRoleToDisplay = useMemo(() => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
- return (Object.entries(recipientsByRole) as [RecipientRole, Recipient[]][]).filter(
+ return (Object.entries(recipientsByRole) as [RecipientRole, TRecipientLite[]][]).filter(
([role]) =>
role !== RecipientRole.CC &&
role !== RecipientRole.VIEWER &&
diff --git a/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx b/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx
index ae562a07f..011011f57 100644
--- a/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx
+++ b/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx
@@ -7,7 +7,7 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { TemplateDirectLink } from '@prisma/client';
-import { DocumentSigningOrder, type Field, type Recipient, RecipientRole } from '@prisma/client';
+import { DocumentSigningOrder, type Field, RecipientRole } from '@prisma/client';
import { motion } from 'framer-motion';
import { GripVerticalIcon, HelpCircle, Link2Icon, Plus, Trash } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form';
@@ -17,6 +17,7 @@ import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/org
import { useSession } from '@documenso/lib/client-only/providers/session';
import { isTemplateRecipientEmailPlaceholder } from '@documenso/lib/constants/template';
import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
+import type { TRecipientLite } from '@documenso/lib/types/recipient';
import { nanoid } from '@documenso/lib/universal/id';
import { generateRecipientPlaceholder } from '@documenso/lib/utils/templates';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
@@ -49,12 +50,12 @@ import type { TAddTemplatePlacholderRecipientsFormSchema } from './add-template-
import { ZAddTemplatePlacholderRecipientsFormSchema } from './add-template-placeholder-recipients.types';
type AutoSaveResponse = {
- recipients: Recipient[];
+ recipients: TRecipientLite[];
};
export type AddTemplatePlaceholderRecipientsFormProps = {
documentFlow: DocumentFlowStep;
- recipients: Recipient[];
+ recipients: TRecipientLite[];
fields: Field[];
signingOrder?: DocumentSigningOrder | null;
allowDictateNextSigner?: boolean;
diff --git a/packages/ui/primitives/template-flow/add-template-settings.tsx b/packages/ui/primitives/template-flow/add-template-settings.tsx
index cbb9137ac..b1ae42bdf 100644
--- a/packages/ui/primitives/template-flow/add-template-settings.tsx
+++ b/packages/ui/primitives/template-flow/add-template-settings.tsx
@@ -3,7 +3,7 @@ import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import { DocumentVisibility, TeamMemberRole, TemplateType } from '@prisma/client';
-import { DocumentDistributionMethod, type Field, type Recipient } from '@prisma/client';
+import { DocumentDistributionMethod, type Field } from '@prisma/client';
import { InfoIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
@@ -19,6 +19,7 @@ import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import type { TDocumentMetaDateFormat } from '@documenso/lib/types/document-meta';
+import type { TRecipientLite } from '@documenso/lib/types/recipient';
import type { TTemplate } from '@documenso/lib/types/template';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams';
@@ -81,7 +82,7 @@ import { ZAddTemplateSettingsFormSchema } from './add-template-settings.types';
export type AddTemplateSettingsFormProps = {
documentFlow: DocumentFlowStep;
- recipients: Recipient[];
+ recipients: TRecipientLite[];
fields: Field[];
isDocumentPdfLoaded: boolean;
template: TTemplate;