feat: signing reminders (#1749)

This commit is contained in:
Ephraim Duncan 2026-04-14 11:01:53 +00:00 committed by GitHub
parent 6d7bd212bf
commit 4935f387bf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
69 changed files with 1426 additions and 156 deletions

View file

@ -182,6 +182,9 @@ git clone https://github.com/<your-username>/documenso
- Optional: Seed the database using `npm run prisma:seed -w @documenso/prisma` to create a test user and document. - 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. - 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)**. - 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 ### Run in Gitpod

View file

@ -4,13 +4,14 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { type Recipient, SigningStatus, type Team, type User } from '@prisma/client'; import { SigningStatus, type Team, type User } from '@prisma/client';
import { History } from 'lucide-react'; import { History } from 'lucide-react';
import { useForm, useWatch } from 'react-hook-form'; import { useForm, useWatch } from 'react-hook-form';
import * as z from 'zod'; import * as z from 'zod';
import { useSession } from '@documenso/lib/client-only/providers/session'; import { useSession } from '@documenso/lib/client-only/providers/session';
import { getRecipientType } from '@documenso/lib/client-only/recipient-type'; 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 { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import type { Document } from '@documenso/prisma/types/document-legacy-schema'; import type { Document } from '@documenso/prisma/types/document-legacy-schema';
import { trpc as trpcReact } from '@documenso/trpc/react'; import { trpc as trpcReact } from '@documenso/trpc/react';
@ -45,10 +46,10 @@ const FORM_ID = 'resend-email';
export type DocumentResendDialogProps = { export type DocumentResendDialogProps = {
document: Pick<Document, 'id' | 'userId' | 'teamId' | 'status'> & { document: Pick<Document, 'id' | 'userId' | 'teamId' | 'status'> & {
user: Pick<User, 'id' | 'name' | 'email'>; user: Pick<User, 'id' | 'name' | 'email'>;
recipients: Recipient[]; recipients: TRecipientLite[];
team: Pick<Team, 'id' | 'url'> | null; team: Pick<Team, 'id' | 'url'> | null;
}; };
recipients: Recipient[]; recipients: TRecipientLite[];
}; };
export const ZResendDocumentFormSchema = z.object({ export const ZResendDocumentFormSchema = z.object({
@ -183,7 +184,7 @@ export const DocumentResendDialog = ({ document, recipients }: DocumentResendDia
<DialogClose asChild> <DialogClose asChild>
<Button <Button
type="button" type="button"
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10" className="flex-1 bg-black/5 hover:bg-black/10 dark:bg-muted dark:hover:bg-muted/80"
variant="secondary" variant="secondary"
disabled={isSubmitting} disabled={isSubmitting}
> >

View file

@ -4,12 +4,13 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro'; import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { DocumentStatus, EnvelopeType, type Recipient, SigningStatus } from '@prisma/client'; import { DocumentStatus, EnvelopeType, SigningStatus } from '@prisma/client';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import * as z from 'zod'; import * as z from 'zod';
import { getRecipientType } from '@documenso/lib/client-only/recipient-type'; import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import type { TEnvelope } from '@documenso/lib/types/envelope'; 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 { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import { trpc as trpcReact } from '@documenso/trpc/react'; import { trpc as trpcReact } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
@ -38,7 +39,7 @@ import { StackAvatar } from '../general/stack-avatar';
export type EnvelopeRedistributeDialogProps = { export type EnvelopeRedistributeDialogProps = {
envelope: Pick<TEnvelope, 'id' | 'userId' | 'teamId' | 'status' | 'type' | 'documentMeta'> & { envelope: Pick<TEnvelope, 'id' | 'userId' | 'teamId' | 'status' | 'type' | 'documentMeta'> & {
recipients: Recipient[]; recipients: TEnvelopeRecipientLite[];
}; };
trigger?: React.ReactNode; trigger?: React.ReactNode;
}; };

View file

@ -26,15 +26,22 @@ import { useCurrentTeam } from '~/providers/team';
type EnvelopeSaveAsTemplateDialogProps = { type EnvelopeSaveAsTemplateDialogProps = {
envelopeId: string; envelopeId: string;
trigger?: React.ReactNode; trigger?: React.ReactNode;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}; };
export const EnvelopeSaveAsTemplateDialog = ({ export const EnvelopeSaveAsTemplateDialog = ({
envelopeId, envelopeId,
trigger, trigger,
open: controlledOpen,
onOpenChange: controlledOnOpenChange,
}: EnvelopeSaveAsTemplateDialogProps) => { }: EnvelopeSaveAsTemplateDialogProps) => {
const navigate = useNavigate(); 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 { toast } = useToast();
const { t } = useLingui(); const { t } = useLingui();

View file

@ -3,7 +3,7 @@ import { useEffect, useMemo, useState } from 'react';
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { type Recipient, RecipientRole, type TemplateDirectLink } from '@prisma/client'; import { RecipientRole, type TemplateDirectLink } from '@prisma/client';
import { import {
CircleDotIcon, CircleDotIcon,
CircleIcon, 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 { DIRECT_TEMPLATE_RECIPIENT_EMAIL } from '@documenso/lib/constants/direct-templates';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { DIRECT_TEMPLATE_DOCUMENTATION } from '@documenso/lib/constants/template'; 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 { formatDirectTemplatePath } from '@documenso/lib/utils/templates';
import { trpc as trpcReact } from '@documenso/trpc/react'; import { trpc as trpcReact } from '@documenso/trpc/react';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out'; 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 = { type TemplateDirectLinkDialogProps = {
templateId: number; templateId: number;
directLink?: Pick<TemplateDirectLink, 'token' | 'enabled'> | null; directLink?: Pick<TemplateDirectLink, 'token' | 'enabled'> | null;
recipients: Recipient[]; recipients: TRecipientLite[];
trigger?: React.ReactNode; trigger?: React.ReactNode;
onCreateSuccess?: () => Promise<void> | void; onCreateSuccess?: () => Promise<void> | void;
onDeleteSuccess?: () => Promise<void> | void; onDeleteSuccess?: () => Promise<void> | void;

View file

@ -4,7 +4,6 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import type { Recipient } from '@prisma/client';
import { DocumentDistributionMethod, DocumentSigningOrder } from '@prisma/client'; import { DocumentDistributionMethod, DocumentSigningOrder } from '@prisma/client';
import { FileTextIcon, InfoIcon, Plus, UploadCloudIcon, X } from 'lucide-react'; import { FileTextIcon, InfoIcon, Plus, UploadCloudIcon, X } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form'; import { useFieldArray, useForm } from 'react-hook-form';
@ -21,7 +20,7 @@ import {
SKIP_QUERY_BATCH_META, SKIP_QUERY_BATCH_META,
} from '@documenso/lib/constants/trpc'; } from '@documenso/lib/constants/trpc';
import { AppError } from '@documenso/lib/errors/app-error'; import { AppError } from '@documenso/lib/errors/app-error';
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient'; import { type TRecipientLite, ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file'; import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
@ -79,7 +78,7 @@ export type TemplateUseDialogProps = {
envelopeId: string; envelopeId: string;
templateId: number; templateId: number;
templateSigningOrder?: DocumentSigningOrder | null; templateSigningOrder?: DocumentSigningOrder | null;
recipients: Recipient[]; recipients: TRecipientLite[];
documentDistributionMethod?: DocumentDistributionMethod; documentDistributionMethod?: DocumentDistributionMethod;
documentRootPath: string; documentRootPath: string;
trigger?: React.ReactNode; trigger?: React.ReactNode;

View file

@ -4,7 +4,7 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import type { EnvelopeItem, FieldType } from '@prisma/client'; import type { EnvelopeItem, FieldType } from '@prisma/client';
import { ReadStatus, type Recipient, SendStatus, SigningStatus } from '@prisma/client'; import { ReadStatus, SendStatus, SigningStatus } from '@prisma/client';
import { ChevronsUpDown } from 'lucide-react'; import { ChevronsUpDown } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form'; import { useFieldArray, useForm } from 'react-hook-form';
import { useHotkeys } from 'react-hotkeys-hook'; 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 { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
import { PDF_VIEWER_PAGE_SELECTOR, getPdfPagesCount } from '@documenso/lib/constants/pdf-viewer'; import { PDF_VIEWER_PAGE_SELECTOR, getPdfPagesCount } from '@documenso/lib/constants/pdf-viewer';
import { type TFieldMetaSchema, ZFieldMetaSchema } from '@documenso/lib/types/field-meta'; 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 { nanoid } from '@documenso/lib/universal/id';
import { ADVANCED_FIELD_TYPES_WITH_OPTIONAL_SETTING } from '@documenso/lib/utils/advanced-fields-helpers'; import { ADVANCED_FIELD_TYPES_WITH_OPTIONAL_SETTING } from '@documenso/lib/utils/advanced-fields-helpers';
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download'; import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
@ -105,7 +106,7 @@ export const ConfigureFieldsView = ({
}, [configData.documentData, envelopeItem, presignToken]); }, [configData.documentData, envelopeItem, presignToken]);
const recipients = useMemo(() => { const recipients = useMemo(() => {
return configData.signers.map<Recipient>((signer, index) => ({ return configData.signers.map<TRecipientLite>((signer, index) => ({
id: signer.nativeId || index, id: signer.nativeId || index,
name: signer.name || '', name: signer.name || '',
email: signer.email || '', email: signer.email || '',
@ -128,7 +129,7 @@ export const ConfigureFieldsView = ({
})); }));
}, [configData.signers]); }, [configData.signers]);
const [selectedRecipient, setSelectedRecipient] = useState<Recipient | null>( const [selectedRecipient, setSelectedRecipient] = useState<TRecipientLite | null>(
() => recipients.find((r) => r.signingStatus === SigningStatus.NOT_SIGNED) || null, () => recipients.find((r) => r.signingStatus === SigningStatus.NOT_SIGNED) || null,
); );
const [selectedField, setSelectedField] = useState<FieldType | null>(null); const [selectedField, setSelectedField] = useState<FieldType | null>(null);

View file

@ -15,6 +15,10 @@ import {
type TEnvelopeExpirationPeriod, type TEnvelopeExpirationPeriod,
ZEnvelopeExpirationPeriod, ZEnvelopeExpirationPeriod,
} from '@documenso/lib/constants/envelope-expiration'; } from '@documenso/lib/constants/envelope-expiration';
import {
type TEnvelopeReminderSettings,
ZEnvelopeReminderSettings,
} from '@documenso/lib/constants/envelope-reminder';
import { import {
SUPPORTED_LANGUAGES, SUPPORTED_LANGUAGES,
SUPPORTED_LANGUAGE_CODES, SUPPORTED_LANGUAGE_CODES,
@ -32,6 +36,7 @@ import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'
import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams'; import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams';
import { DocumentSignatureSettingsTooltip } from '@documenso/ui/components/document/document-signature-settings-tooltip'; import { DocumentSignatureSettingsTooltip } from '@documenso/ui/components/document/document-signature-settings-tooltip';
import { ExpirationPeriodPicker } from '@documenso/ui/components/document/expiration-period-picker'; 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 { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select';
import { Alert } from '@documenso/ui/primitives/alert'; import { Alert } from '@documenso/ui/primitives/alert';
import { AvatarWithText } from '@documenso/ui/primitives/avatar'; import { AvatarWithText } from '@documenso/ui/primitives/avatar';
@ -76,6 +81,7 @@ export type TDocumentPreferencesFormSchema = {
delegateDocumentOwnership: boolean | null; delegateDocumentOwnership: boolean | null;
aiFeaturesEnabled: boolean | null; aiFeaturesEnabled: boolean | null;
envelopeExpirationPeriod: TEnvelopeExpirationPeriod | null; envelopeExpirationPeriod: TEnvelopeExpirationPeriod | null;
reminderSettings: TEnvelopeReminderSettings | null;
}; };
type SettingsSubset = Pick< type SettingsSubset = Pick<
@ -94,6 +100,7 @@ type SettingsSubset = Pick<
| 'delegateDocumentOwnership' | 'delegateDocumentOwnership'
| 'aiFeaturesEnabled' | 'aiFeaturesEnabled'
| 'envelopeExpirationPeriod' | 'envelopeExpirationPeriod'
| 'reminderSettings'
>; >;
export type DocumentPreferencesFormProps = { export type DocumentPreferencesFormProps = {
@ -134,6 +141,7 @@ export const DocumentPreferencesForm = ({
delegateDocumentOwnership: z.boolean().nullable(), delegateDocumentOwnership: z.boolean().nullable(),
aiFeaturesEnabled: z.boolean().nullable(), aiFeaturesEnabled: z.boolean().nullable(),
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullable(), envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullable(),
reminderSettings: ZEnvelopeReminderSettings.nullable(),
}); });
const form = useForm<TDocumentPreferencesFormSchema>({ const form = useForm<TDocumentPreferencesFormSchema>({
@ -155,6 +163,7 @@ export const DocumentPreferencesForm = ({
delegateDocumentOwnership: settings.delegateDocumentOwnership, delegateDocumentOwnership: settings.delegateDocumentOwnership,
aiFeaturesEnabled: settings.aiFeaturesEnabled, aiFeaturesEnabled: settings.aiFeaturesEnabled,
envelopeExpirationPeriod: settings.envelopeExpirationPeriod ?? null, envelopeExpirationPeriod: settings.envelopeExpirationPeriod ?? null,
reminderSettings: settings.reminderSettings ?? null,
}, },
resolver: zodResolver(ZDocumentPreferencesFormSchema), resolver: zodResolver(ZDocumentPreferencesFormSchema),
}); });
@ -707,6 +716,35 @@ export const DocumentPreferencesForm = ({
)} )}
/> />
<FormField
control={form.control}
name="reminderSettings"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Default Signing Reminders</Trans>
</FormLabel>
<FormControl>
<ReminderSettingsPicker
value={field.value}
onChange={field.onChange}
inheritLabel={canInherit ? t`Inherit from organisation` : undefined}
/>
</FormControl>
<FormDescription>
<Trans>
Controls when and how often reminder emails are sent to recipients who have not
yet completed signing.
</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{isAiFeaturesConfigured && ( {isAiFeaturesConfigured && (
<FormField <FormField
control={form.control} control={form.control}

View file

@ -1,12 +1,12 @@
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import type { Recipient } from '@prisma/client';
import { DocumentStatus } from '@prisma/client'; import { DocumentStatus } from '@prisma/client';
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard'; import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
import { getRecipientType } from '@documenso/lib/client-only/recipient-type'; import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; 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 { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
@ -14,7 +14,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { StackAvatar } from './stack-avatar'; import { StackAvatar } from './stack-avatar';
export type AvatarWithRecipientProps = { export type AvatarWithRecipientProps = {
recipient: Recipient; recipient: TRecipientLite;
documentStatus: DocumentStatus; documentStatus: DocumentStatus;
}; };

View file

@ -56,6 +56,7 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
const trpcUtils = trpcReact.useUtils(); const trpcUtils = trpcReact.useUtils();
const [isRenameDialogOpen, setRenameDialogOpen] = useState(false); const [isRenameDialogOpen, setRenameDialogOpen] = useState(false);
const [isSaveAsTemplateDialogOpen, setSaveAsTemplateDialogOpen] = useState(false);
const recipient = envelope.recipients.find((recipient) => recipient.email === user.email); const recipient = envelope.recipients.find((recipient) => recipient.email === user.email);
@ -135,17 +136,10 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
} }
/> />
<EnvelopeSaveAsTemplateDialog <DropdownMenuItem onClick={() => setSaveAsTemplateDialogOpen(true)}>
envelopeId={envelope.id} <FileOutputIcon className="mr-2 h-4 w-4" />
trigger={ <Trans>Save as Template</Trans>
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}> </DropdownMenuItem>
<div>
<FileOutputIcon className="mr-2 h-4 w-4" />
<Trans>Save as Template</Trans>
</div>
</DropdownMenuItem>
}
/>
<EnvelopeDeleteDialog <EnvelopeDeleteDialog
id={envelope.id} id={envelope.id}
@ -207,6 +201,12 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
/> />
</DropdownMenuContent> </DropdownMenuContent>
<EnvelopeSaveAsTemplateDialog
envelopeId={envelope.id}
open={isSaveAsTemplateDialogOpen}
onOpenChange={setSaveAsTemplateDialogOpen}
/>
<EnvelopeRenameDialog <EnvelopeRenameDialog
id={envelope.id} id={envelope.id}
initialTitle={envelope.title} initialTitle={envelope.title}

View file

@ -3,7 +3,6 @@ import { useEffect, useState } from 'react';
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import type { Recipient } from '@prisma/client';
import { RecipientRole } from '@prisma/client'; import { RecipientRole } from '@prisma/client';
import { useSearchParams } from 'react-router'; import { useSearchParams } from 'react-router';
@ -11,6 +10,7 @@ import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import type { TRecipientLite } from '@documenso/lib/types/recipient';
import { formatSigningLink } from '@documenso/lib/utils/recipients'; import { formatSigningLink } from '@documenso/lib/utils/recipients';
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button'; import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
import { AvatarWithText } from '@documenso/ui/primitives/avatar'; import { AvatarWithText } from '@documenso/ui/primitives/avatar';
@ -29,7 +29,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
export type DocumentRecipientLinkCopyDialogProps = { export type DocumentRecipientLinkCopyDialogProps = {
trigger?: React.ReactNode; trigger?: React.ReactNode;
recipients: Recipient[]; recipients: TRecipientLite[];
}; };
export const DocumentRecipientLinkCopyDialog = ({ export const DocumentRecipientLinkCopyDialog = ({
@ -88,7 +88,7 @@ export const DocumentRecipientLinkCopyDialog = ({
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<ul className="text-muted-foreground divide-y rounded-lg border"> <ul className="divide-y rounded-lg border text-muted-foreground">
{recipients.length === 0 && ( {recipients.length === 0 && (
<li className="flex flex-col items-center justify-center py-6 text-sm"> <li className="flex flex-col items-center justify-center py-6 text-sm">
<Trans>No recipients</Trans> <Trans>No recipients</Trans>
@ -99,9 +99,9 @@ export const DocumentRecipientLinkCopyDialog = ({
<li key={recipient.id} className="flex items-center justify-between px-4 py-3 text-sm"> <li key={recipient.id} className="flex items-center justify-between px-4 py-3 text-sm">
<AvatarWithText <AvatarWithText
avatarFallback={recipient.email.slice(0, 1).toUpperCase()} avatarFallback={recipient.email.slice(0, 1).toUpperCase()}
primaryText={<p className="text-muted-foreground text-sm">{recipient.email}</p>} primaryText={<p className="text-sm text-muted-foreground">{recipient.email}</p>}
secondaryText={ secondaryText={
<p className="text-muted-foreground/70 text-xs"> <p className="text-xs text-muted-foreground/70">
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)} {_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
</p> </p>
} }

View file

@ -13,7 +13,7 @@ import {
TemplateType, TemplateType,
} from '@prisma/client'; } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog'; 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 { useForm } from 'react-hook-form';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { z } from 'zod'; import { z } from 'zod';
@ -26,6 +26,7 @@ import {
DOCUMENT_SIGNATURE_TYPES, DOCUMENT_SIGNATURE_TYPES,
} from '@documenso/lib/constants/document'; } from '@documenso/lib/constants/document';
import { ZEnvelopeExpirationPeriod } from '@documenso/lib/constants/envelope-expiration'; import { ZEnvelopeExpirationPeriod } from '@documenso/lib/constants/envelope-expiration';
import { ZEnvelopeReminderSettings } from '@documenso/lib/constants/envelope-reminder';
import { import {
SUPPORTED_LANGUAGES, SUPPORTED_LANGUAGES,
SUPPORTED_LANGUAGE_CODES, SUPPORTED_LANGUAGE_CODES,
@ -69,6 +70,7 @@ import {
DocumentVisibilityTooltip, DocumentVisibilityTooltip,
} from '@documenso/ui/components/document/document-visibility-select'; } from '@documenso/ui/components/document/document-visibility-select';
import { ExpirationPeriodPicker } from '@documenso/ui/components/document/expiration-period-picker'; import { ExpirationPeriodPicker } from '@documenso/ui/components/document/expiration-period-picker';
import { ReminderSettingsPicker } from '@documenso/ui/components/document/reminder-settings-picker';
import { import {
TemplateTypeSelect, TemplateTypeSelect,
TemplateTypeTooltip, TemplateTypeTooltip,
@ -145,10 +147,11 @@ export const ZAddSettingsFormSchema = z.object({
message: msg`At least one signature type must be enabled`.id, message: msg`At least one signature type must be enabled`.id,
}), }),
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullish(), envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullish(),
reminderSettings: ZEnvelopeReminderSettings.nullish(),
}), }),
}); });
type EnvelopeEditorSettingsTabType = 'general' | 'email' | 'security'; type EnvelopeEditorSettingsTabType = 'general' | 'reminders' | 'email' | 'security';
const tabs = [ const tabs = [
{ {
@ -157,6 +160,12 @@ const tabs = [
icon: SettingsIcon, icon: SettingsIcon,
description: msg`Configure document settings and options before sending.`, 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', id: 'email',
title: msg`Email`, title: msg`Email`,
@ -222,6 +231,7 @@ export const EnvelopeEditorSettingsDialog = ({
emailSettings: ZDocumentEmailSettingsSchema.parse(envelope.documentMeta.emailSettings), emailSettings: ZDocumentEmailSettingsSchema.parse(envelope.documentMeta.emailSettings),
signatureTypes: extractTeamSignatureSettings(envelope.documentMeta), signatureTypes: extractTeamSignatureSettings(envelope.documentMeta),
envelopeExpirationPeriod: envelope.documentMeta?.envelopeExpirationPeriod ?? null, envelopeExpirationPeriod: envelope.documentMeta?.envelopeExpirationPeriod ?? null,
reminderSettings: envelope.documentMeta?.reminderSettings ?? null,
}, },
}; };
}; };
@ -270,6 +280,7 @@ export const EnvelopeEditorSettingsDialog = ({
subject, subject,
emailReplyTo, emailReplyTo,
envelopeExpirationPeriod, envelopeExpirationPeriod,
reminderSettings,
} = data.meta; } = data.meta;
const parsedGlobalAccessAuth = z const parsedGlobalAccessAuth = z
@ -300,6 +311,7 @@ export const EnvelopeEditorSettingsDialog = ({
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE), typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD), uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
envelopeExpirationPeriod, envelopeExpirationPeriod,
reminderSettings,
}, },
}); });
@ -381,6 +393,10 @@ export const EnvelopeEditorSettingsDialog = ({
return null; return null;
} }
if (tab.id === 'reminders' && !settings.allowConfigureReminders) {
return null;
}
return ( return (
<Button <Button
key={tab.id} key={tab.id}
@ -751,6 +767,44 @@ export const EnvelopeEditorSettingsDialog = ({
)} )}
</> </>
)) ))
.with(
{ activeTab: 'reminders', settings: { allowConfigureReminders: true } },
() => (
<FormField
control={form.control}
name="meta.reminderSettings"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>Signing Reminders</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="max-w-xs text-muted-foreground">
<Trans>
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.
</Trans>
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<ReminderSettingsPicker
value={field.value}
onChange={field.onChange}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
),
)
.with( .with(
{ activeTab: 'email', settings: { allowConfigureDistribution: true } }, { activeTab: 'email', settings: { allowConfigureDistribution: true } },
() => ( () => (

View file

@ -4,12 +4,13 @@ import type { I18n } from '@lingui/core';
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro'; import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import type { Field, Recipient } from '@prisma/client'; import type { Field } from '@prisma/client';
import { RecipientRole, SendStatus } from '@prisma/client'; import { RecipientRole, SendStatus } from '@prisma/client';
import { Check, ChevronsUpDown, Info } from 'lucide-react'; import { Check, ChevronsUpDown, Info } from 'lucide-react';
import { sortBy } from 'remeda'; import { sortBy } from 'remeda';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; 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 { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
import { getRecipientColorStyles } from '@documenso/ui/lib/recipient-colors'; import { getRecipientColorStyles } from '@documenso/ui/lib/recipient-colors';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
@ -26,9 +27,9 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitive
export interface EnvelopeRecipientSelectorProps { export interface EnvelopeRecipientSelectorProps {
className?: string; className?: string;
selectedRecipient: Recipient | null; selectedRecipient: TEnvelopeRecipientLite | null;
onSelectedRecipientChange: (recipient: Recipient) => void; onSelectedRecipientChange: (recipient: TEnvelopeRecipientLite) => void;
recipients: Recipient[]; recipients: TEnvelopeRecipientLite[];
fields: Field[]; fields: Field[];
align?: 'center' | 'end' | 'start'; align?: 'center' | 'end' | 'start';
} }
@ -46,7 +47,7 @@ export const EnvelopeRecipientSelector = ({
const [showRecipientsSelector, setShowRecipientsSelector] = useState(false); const [showRecipientsSelector, setShowRecipientsSelector] = useState(false);
const getRecipientLabel = useCallback( const getRecipientLabel = useCallback(
(recipient: Recipient) => extractRecipientLabel(recipient, recipients, i18n), (recipient: TEnvelopeRecipientLite) => extractRecipientLabel(recipient, recipients, i18n),
[recipients], [recipients],
); );
@ -91,9 +92,9 @@ export const EnvelopeRecipientSelector = ({
interface EnvelopeRecipientSelectorCommandProps { interface EnvelopeRecipientSelectorCommandProps {
className?: string; className?: string;
selectedRecipient: Recipient | null; selectedRecipient: TEnvelopeRecipientLite | null;
onSelectedRecipientChange: (recipient: Recipient) => void; onSelectedRecipientChange: (recipient: TEnvelopeRecipientLite) => void;
recipients: Recipient[]; recipients: TEnvelopeRecipientLite[];
fields: Field[]; fields: Field[];
placeholder?: string; placeholder?: string;
} }
@ -109,7 +110,7 @@ export const EnvelopeRecipientSelectorCommand = ({
const { t, i18n } = useLingui(); const { t, i18n } = useLingui();
const recipientsByRole = useCallback(() => { const recipientsByRole = useCallback(() => {
const recipientsByRole: Record<RecipientRole, Recipient[]> = { const recipientsByRole: Record<RecipientRole, TEnvelopeRecipientLite[]> = {
CC: [], CC: [],
VIEWER: [], VIEWER: [],
SIGNER: [], SIGNER: [],
@ -141,7 +142,7 @@ export const EnvelopeRecipientSelectorCommand = ({
[(r) => r.signingOrder || Number.MAX_SAFE_INTEGER, 'asc'], [(r) => r.signingOrder || Number.MAX_SAFE_INTEGER, 'asc'],
[(r) => r.id, 'asc'], [(r) => r.id, 'asc'],
), ),
] as [RecipientRole, Recipient[]], ] as [RecipientRole, TEnvelopeRecipientLite[]],
); );
}, [recipientsByRole]); }, [recipientsByRole]);
@ -156,7 +157,7 @@ export const EnvelopeRecipientSelectorCommand = ({
); );
const getRecipientLabel = useCallback( const getRecipientLabel = useCallback(
(recipient: Recipient) => extractRecipientLabel(recipient, recipients, i18n), (recipient: TEnvelopeRecipientLite) => extractRecipientLabel(recipient, recipients, i18n),
[recipients], [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) { if (recipient.name && recipient.email) {
return `${recipient.name} (${recipient.email})`; return `${recipient.name} (${recipient.email})`;
} }

View file

@ -2,10 +2,11 @@ import { useMemo } from 'react';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; 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 { RecipientStatusType, getRecipientType } from '@documenso/lib/client-only/recipient-type';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; 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 { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import { PopoverHover } from '@documenso/ui/primitives/popover'; import { PopoverHover } from '@documenso/ui/primitives/popover';
@ -15,7 +16,7 @@ import { StackAvatars } from './stack-avatars';
export type StackAvatarsWithTooltipProps = { export type StackAvatarsWithTooltipProps = {
documentStatus: DocumentStatus; documentStatus: DocumentStatus;
recipients: Recipient[]; recipients: TRecipientLite[];
position?: 'top' | 'bottom'; position?: 'top' | 'bottom';
children?: React.ReactNode; children?: React.ReactNode;
}; };
@ -74,7 +75,7 @@ export const StackAvatarsWithTooltip = ({
<h1 className="text-base font-medium"> <h1 className="text-base font-medium">
<Trans>Completed</Trans> <Trans>Completed</Trans>
</h1> </h1>
{completedRecipients.map((recipient: Recipient) => ( {completedRecipients.map((recipient) => (
<div key={recipient.id} className="my-1 flex items-center gap-2"> <div key={recipient.id} className="my-1 flex items-center gap-2">
<StackAvatar <StackAvatar
first={true} first={true}
@ -98,7 +99,7 @@ export const StackAvatarsWithTooltip = ({
<h1 className="text-base font-medium"> <h1 className="text-base font-medium">
<Trans>Rejected</Trans> <Trans>Rejected</Trans>
</h1> </h1>
{rejectedRecipients.map((recipient: Recipient) => ( {rejectedRecipients.map((recipient) => (
<div key={recipient.id} className="my-1 flex items-center gap-2"> <div key={recipient.id} className="my-1 flex items-center gap-2">
<StackAvatar <StackAvatar
first={true} first={true}
@ -122,7 +123,7 @@ export const StackAvatarsWithTooltip = ({
<h1 className="text-base font-medium"> <h1 className="text-base font-medium">
<Trans>Waiting</Trans> <Trans>Waiting</Trans>
</h1> </h1>
{waitingRecipients.map((recipient: Recipient) => ( {waitingRecipients.map((recipient) => (
<AvatarWithRecipient <AvatarWithRecipient
key={recipient.id} key={recipient.id}
recipient={recipient} recipient={recipient}
@ -137,7 +138,7 @@ export const StackAvatarsWithTooltip = ({
<h1 className="text-base font-medium"> <h1 className="text-base font-medium">
<Trans>Opened</Trans> <Trans>Opened</Trans>
</h1> </h1>
{openedRecipients.map((recipient: Recipient) => ( {openedRecipients.map((recipient) => (
<AvatarWithRecipient <AvatarWithRecipient
key={recipient.id} key={recipient.id}
recipient={recipient} recipient={recipient}
@ -152,7 +153,7 @@ export const StackAvatarsWithTooltip = ({
<h1 className="text-base font-medium"> <h1 className="text-base font-medium">
<Trans>Uncompleted</Trans> <Trans>Uncompleted</Trans>
</h1> </h1>
{uncompletedRecipients.map((recipient: Recipient) => ( {uncompletedRecipients.map((recipient) => (
<AvatarWithRecipient <AvatarWithRecipient
key={recipient.id} key={recipient.id}
recipient={recipient} recipient={recipient}

View file

@ -1,22 +1,21 @@
import React from 'react'; import React from 'react';
import type { Recipient } from '@prisma/client';
import { import {
getExtraRecipientsType, getExtraRecipientsType,
getRecipientType, getRecipientType,
} from '@documenso/lib/client-only/recipient-type'; } from '@documenso/lib/client-only/recipient-type';
import type { TRecipientLite } from '@documenso/lib/types/recipient';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import { StackAvatar } from './stack-avatar'; import { StackAvatar } from './stack-avatar';
export function StackAvatars({ recipients }: { recipients: Recipient[] }) { export function StackAvatars({ recipients }: { recipients: TRecipientLite[] }) {
const renderStackAvatars = (recipients: Recipient[]) => { const renderStackAvatars = (recipients: TRecipientLite[]) => {
const zIndex = 50; const zIndex = 50;
const itemsToRender = recipients.slice(0, 5); const itemsToRender = recipients.slice(0, 5);
const remainingItems = recipients.length - itemsToRender.length; const remainingItems = recipients.length - itemsToRender.length;
return itemsToRender.map((recipient: Recipient, index: number) => { return itemsToRender.map((recipient, index: number) => {
const first = index === 0; const first = index === 0;
if (index === 4 && remainingItems > 0) { if (index === 4 && remainingItems > 0) {

View file

@ -1,17 +1,17 @@
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import type { Recipient } from '@prisma/client';
import { PenIcon, PlusIcon } from 'lucide-react'; import { PenIcon, PlusIcon } from 'lucide-react';
import { Link } from 'react-router'; import { Link } from 'react-router';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { isTemplateRecipientEmailPlaceholder } from '@documenso/lib/constants/template'; import { isTemplateRecipientEmailPlaceholder } from '@documenso/lib/constants/template';
import type { TRecipientLite } from '@documenso/lib/types/recipient';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter'; import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { AvatarWithText } from '@documenso/ui/primitives/avatar'; import { AvatarWithText } from '@documenso/ui/primitives/avatar';
export type TemplatePageViewRecipientsProps = { export type TemplatePageViewRecipientsProps = {
recipients: Recipient[]; recipients: TRecipientLite[];
envelopeId: string; envelopeId: string;
templateRootPath: string; templateRootPath: string;
readOnly?: boolean; readOnly?: boolean;

View file

@ -62,6 +62,7 @@ export const DocumentsTableActionDropdown = ({
const trpcUtils = trpcReact.useUtils(); const trpcUtils = trpcReact.useUtils();
const [isRenameDialogOpen, setRenameDialogOpen] = useState(false); const [isRenameDialogOpen, setRenameDialogOpen] = useState(false);
const [isSaveAsTemplateDialogOpen, setSaveAsTemplateDialogOpen] = useState(false);
const recipient = findRecipientByEmail({ const recipient = findRecipientByEmail({
recipients: row.recipients, recipients: row.recipients,
@ -175,17 +176,10 @@ export const DocumentsTableActionDropdown = ({
} }
/> />
<EnvelopeSaveAsTemplateDialog <DropdownMenuItem onClick={() => setSaveAsTemplateDialogOpen(true)}>
envelopeId={row.envelopeId} <FileOutputIcon className="mr-2 h-4 w-4" />
trigger={ <Trans>Save as Template</Trans>
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}> </DropdownMenuItem>
<div>
<FileOutputIcon className="mr-2 h-4 w-4" />
<Trans>Save as Template</Trans>
</div>
</DropdownMenuItem>
}
/>
{onMoveDocument && canManageDocument && ( {onMoveDocument && canManageDocument && (
<DropdownMenuItem onClick={onMoveDocument} onSelect={(e) => e.preventDefault()}> <DropdownMenuItem onClick={onMoveDocument} onSelect={(e) => e.preventDefault()}>
@ -250,6 +244,12 @@ export const DocumentsTableActionDropdown = ({
/> />
</DropdownMenuContent> </DropdownMenuContent>
<EnvelopeSaveAsTemplateDialog
envelopeId={row.envelopeId}
open={isSaveAsTemplateDialogOpen}
onOpenChange={setSaveAsTemplateDialogOpen}
/>
<EnvelopeRenameDialog <EnvelopeRenameDialog
id={row.envelopeId} id={row.envelopeId}
initialTitle={row.title} initialTitle={row.title}

View file

@ -19,6 +19,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { Link } from 'react-router'; import { Link } from 'react-router';
import type { TRecipientLite } from '@documenso/lib/types/recipient';
import { trpc as trpcReact } from '@documenso/trpc/react'; import { trpc as trpcReact } from '@documenso/trpc/react';
import { import {
DropdownMenu, DropdownMenu,
@ -44,7 +45,7 @@ export type TemplatesTableActionDropdownProps = {
folderId?: string | null; folderId?: string | null;
envelopeId: string; envelopeId: string;
directLink?: Pick<TemplateDirectLink, 'token' | 'enabled'> | null; directLink?: Pick<TemplateDirectLink, 'token' | 'enabled'> | null;
recipients: Recipient[]; recipients: TRecipientLite[];
}; };
templateRootPath: string; templateRootPath: string;
teamId: number; teamId: number;

View file

@ -62,6 +62,7 @@ export default function OrganisationSettingsDocumentPage() {
delegateDocumentOwnership, delegateDocumentOwnership,
aiFeaturesEnabled, aiFeaturesEnabled,
envelopeExpirationPeriod, envelopeExpirationPeriod,
reminderSettings,
} = data; } = data;
if ( if (
@ -93,6 +94,7 @@ export default function OrganisationSettingsDocumentPage() {
delegateDocumentOwnership: delegateDocumentOwnership, delegateDocumentOwnership: delegateDocumentOwnership,
aiFeaturesEnabled, aiFeaturesEnabled,
envelopeExpirationPeriod: envelopeExpirationPeriod ?? undefined, envelopeExpirationPeriod: envelopeExpirationPeriod ?? undefined,
reminderSettings: reminderSettings ?? undefined,
}, },
}); });

View file

@ -55,6 +55,7 @@ export default function TeamsSettingsPage() {
delegateDocumentOwnership, delegateDocumentOwnership,
aiFeaturesEnabled, aiFeaturesEnabled,
envelopeExpirationPeriod, envelopeExpirationPeriod,
reminderSettings,
} = data; } = data;
await updateTeamSettings({ await updateTeamSettings({
@ -70,6 +71,7 @@ export default function TeamsSettingsPage() {
defaultRecipients, defaultRecipients,
aiFeaturesEnabled, aiFeaturesEnabled,
envelopeExpirationPeriod, envelopeExpirationPeriod,
reminderSettings,
...(signatureTypes.length === 0 ...(signatureTypes.length === 0
? { ? {
typedSignatureEnabled: null, typedSignatureEnabled: null,

View file

@ -31,6 +31,12 @@ const TEST_SETTINGS_VALUES = {
expirationMode: 'Custom duration', expirationMode: 'Custom duration',
expirationAmount: 5, expirationAmount: 5,
expirationUnit: 'Weeks', expirationUnit: 'Weeks',
reminderMode: 'Enabled',
reminderSendAfterAmount: 3,
reminderSendAfterUnit: 'Days',
reminderRepeatMode: 'Custom interval',
reminderRepeatAmount: 7,
reminderRepeatUnit: 'Days',
accessAuth: 'Require account', accessAuth: 'Require account',
actionAuth: 'Require password', actionAuth: 'Require password',
visibility: 'Managers and above', visibility: 'Managers and above',
@ -42,6 +48,10 @@ const DB_EXPECTED_VALUES = {
timezone: 'Europe/London', timezone: 'Europe/London',
distributionMethod: DocumentDistributionMethod.NONE, distributionMethod: DocumentDistributionMethod.NONE,
envelopeExpirationPeriod: { unit: 'week', amount: 5 }, envelopeExpirationPeriod: { unit: 'week', amount: 5 },
reminderSettings: {
sendAfter: { unit: 'day', amount: 3 },
repeatEvery: { unit: 'day', amount: 7 },
},
visibility: DocumentVisibility.MANAGER_AND_ABOVE, visibility: DocumentVisibility.MANAGER_AND_ABOVE,
globalAccessAuth: ['ACCOUNT'], globalAccessAuth: ['ACCOUNT'],
globalActionAuth: ['PASSWORD'], globalActionAuth: ['PASSWORD'],
@ -130,6 +140,66 @@ const runSettingsFlow = async (
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.expirationUnit }).click(); await root.getByRole('option', { name: TEST_SETTINGS_VALUES.expirationUnit }).click();
await clickSettingsDialogHeader(root); 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.getByRole('button', { name: 'Email' }).click();
await root.locator('#recipientSigned').click(); await root.locator('#recipientSigned').click();
await root.locator('#recipientSigningRequest').click(); await root.locator('#recipientSigningRequest').click();
@ -200,6 +270,27 @@ const runSettingsFlow = async (
.first(), .first(),
).toBeVisible(); ).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 root.getByRole('button', { name: 'Email' }).click();
await expect(root.locator('#recipientSigned')).toHaveAttribute('aria-checked', 'false'); await expect(root.locator('#recipientSigned')).toHaveAttribute('aria-checked', 'false');
await expect(root.locator('#recipientSigningRequest')).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( expect(envelope.documentMeta.envelopeExpirationPeriod).toEqual(
DB_EXPECTED_VALUES.envelopeExpirationPeriod, 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.redirectUrl).toBe(TEST_SETTINGS_VALUES.redirectUrl);
expect(envelope.documentMeta.emailReplyTo).toBe(TEST_SETTINGS_VALUES.replyTo); expect(envelope.documentMeta.emailReplyTo).toBe(TEST_SETTINGS_VALUES.replyTo);
expect(envelope.documentMeta.subject).toBe(TEST_SETTINGS_VALUES.subject); expect(envelope.documentMeta.subject).toBe(TEST_SETTINGS_VALUES.subject);

View file

@ -25,7 +25,7 @@ test('[ENVELOPE_EXPIRATION]: set custom expiration period at organisation level'
await expect(page.getByRole('button', { name: 'Update' }).first()).toBeVisible(); await expect(page.getByRole('button', { name: 'Update' }).first()).toBeVisible();
// Change the amount to 2. // Change the amount to 2.
const amountInput = page.getByRole('spinbutton'); const amountInput = page.getByTestId('envelope-expiration-amount');
await amountInput.clear(); await amountInput.clear();
await amountInput.fill('2'); 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. // 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. // The unit select is inside the duration row, after the number input.
// Let's find the select trigger that contains the unit text. // Let's find the select trigger that contains the unit text.
const unitTrigger = page const unitTrigger = page.getByTestId('envelope-expiration-unit');
.locator('button[role="combobox"]')
.filter({ hasText: /Months|Days|Weeks|Years/ });
await unitTrigger.click(); await unitTrigger.click();
await page.getByRole('option', { name: 'Weeks' }).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(); await expect(page.getByRole('button', { name: 'Update' }).first()).toBeVisible();
// Find the mode select (shows "Custom duration") and change to "Never expires". // Find the mode select (shows "Custom duration") and change to "Never expires".
const modeTrigger = page const modeTrigger = page.getByTestId('envelope-expiration-mode');
.locator('button[role="combobox"]')
.filter({ hasText: 'Custom duration' });
await modeTrigger.click(); await modeTrigger.click();
await page.getByRole('option', { name: 'Never expires' }).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(); 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. // 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(); await expect(modeTrigger).toBeVisible();
// Switch to custom duration. // 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(); await page.getByRole('option', { name: 'Custom duration' }).click();
// Set to 5 days. // Set to 5 days.
const amountInput = expirationSection.getByRole('spinbutton'); const amountInput = page.getByTestId('envelope-expiration-amount');
await amountInput.clear(); await amountInput.clear();
await amountInput.fill('5'); await amountInput.fill('5');
const unitTrigger = expirationSection const unitTrigger = page.getByTestId('envelope-expiration-unit');
.locator('button[role="combobox"]')
.filter({ hasText: /Months|Days|Weeks|Years/ });
await unitTrigger.click(); await unitTrigger.click();
await page.getByRole('option', { name: 'Days' }).click(); await page.getByRole('option', { name: 'Days' }).click();

View file

@ -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 (
<>
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
<Section>
<Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold">
<Trans>
Reminder: Please {_(actionVerb).toLowerCase()} your document
<br />"{documentName}"
</Trans>
</Text>
<Text className="my-1 text-center text-base text-slate-400">
<Trans>Hi {recipientName},</Trans>
</Text>
<Text className="my-1 text-center text-base text-slate-400">
{match(role)
.with(RecipientRole.SIGNER, () => <Trans>Continue by signing the document.</Trans>)
.with(RecipientRole.VIEWER, () => <Trans>Continue by viewing the document.</Trans>)
.with(RecipientRole.APPROVER, () => <Trans>Continue by approving the document.</Trans>)
.with(RecipientRole.CC, () => '')
.with(RecipientRole.ASSISTANT, () => (
<Trans>Continue by assisting with the document.</Trans>
))
.exhaustive()}
</Text>
<Section className="mb-6 mt-8 text-center">
<Button
className="bg-documenso-500 inline-flex items-center justify-center rounded-lg px-6 py-3 text-center text-sm font-medium text-black no-underline"
href={signDocumentLink}
>
{match(role)
.with(RecipientRole.SIGNER, () => <Trans>Sign Document</Trans>)
.with(RecipientRole.VIEWER, () => <Trans>View Document</Trans>)
.with(RecipientRole.APPROVER, () => <Trans>Approve Document</Trans>)
.with(RecipientRole.CC, () => '')
.with(RecipientRole.ASSISTANT, () => <Trans>Assist Document</Trans>)
.exhaustive()}
</Button>
</Section>
</Section>
</>
);
};
export default TemplateDocumentReminder;

View file

@ -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 (
<Html>
<Head />
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto bg-white font-sans">
<Section>
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-4 backdrop-blur-sm">
<Section>
{branding.brandingEnabled && branding.brandingLogo ? (
<Img src={branding.brandingLogo} alt="Branding Logo" className="mb-4 h-6" />
) : (
<Img
src={getAssetUrl('/static/logo.png')}
alt="Documenso Logo"
className="mb-4 h-6"
/>
)}
<TemplateDocumentReminder
recipientName={recipientName}
documentName={documentName}
signDocumentLink={signDocumentLink}
assetBaseUrl={assetBaseUrl}
role={role}
/>
</Section>
</Container>
{customBody && (
<Container className="mx-auto mt-12 max-w-xl">
<Section>
<Text className="mt-2 text-base text-slate-400">
<TemplateCustomMessageBody text={customBody} />
</Text>
</Section>
</Container>
)}
<Hr className="mx-auto mt-12 max-w-xl" />
<Container className="mx-auto max-w-xl">
<TemplateFooter />
</Container>
</Section>
</Body>
</Html>
);
};
export default DocumentReminderEmailTemplate;

View file

@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod'; 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 { FieldType } from '@prisma/client';
import { useFieldArray, useForm } from 'react-hook-form'; import { useFieldArray, useForm } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
@ -61,7 +61,7 @@ type UseEditorFieldsResponse = {
getFieldsByRecipient: (recipientId: number) => TLocalField[]; getFieldsByRecipient: (recipientId: number) => TLocalField[];
// Selected recipient // Selected recipient
selectedRecipient: Recipient | null; selectedRecipient: TEditorEnvelope['recipients'][number] | null;
setSelectedRecipient: (recipientId: number | null) => void; setSelectedRecipient: (recipientId: number | null) => void;
resetForm: (fields?: Field[]) => void; resetForm: (fields?: Field[]) => void;

View file

@ -1,7 +1,7 @@
import { useId } from 'react'; import { useId } from 'react';
import { zodResolver } from '@hookform/resolvers/zod'; 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 type { UseFormReturn } from 'react-hook-form';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { prop, sortBy } from 'remeda'; import { prop, sortBy } from 'remeda';
@ -39,7 +39,7 @@ type EditorRecipientsProps = {
}; };
type ResetFormOptions = { type ResetFormOptions = {
recipients?: Recipient[]; recipients?: TEditorEnvelope['recipients'];
documentMeta?: TEditorEnvelope['documentMeta']; documentMeta?: TEditorEnvelope['documentMeta'];
}; };

View file

@ -7,6 +7,8 @@ import {
SigningStatus, SigningStatus,
} from '@prisma/client'; } from '@prisma/client';
type RecipientForType = Pick<Recipient, 'role' | 'signingStatus' | 'readStatus' | 'sendStatus'>;
export enum RecipientStatusType { export enum RecipientStatusType {
COMPLETED = 'completed', COMPLETED = 'completed',
OPENED = 'opened', OPENED = 'opened',
@ -16,7 +18,7 @@ export enum RecipientStatusType {
} }
export const getRecipientType = ( export const getRecipientType = (
recipient: Recipient, recipient: RecipientForType,
distributionMethod: DocumentDistributionMethod = DocumentDistributionMethod.EMAIL, distributionMethod: DocumentDistributionMethod = DocumentDistributionMethod.EMAIL,
) => { ) => {
if (recipient.role === RecipientRole.CC || recipient.signingStatus === SigningStatus.SIGNED) { if (recipient.role === RecipientRole.CC || recipient.signingStatus === SigningStatus.SIGNED) {
@ -45,7 +47,7 @@ export const getRecipientType = (
return RecipientStatusType.UNSIGNED; return RecipientStatusType.UNSIGNED;
}; };
export const getExtraRecipientsType = (extraRecipients: Recipient[]) => { export const getExtraRecipientsType = (extraRecipients: RecipientForType[]) => {
const types = extraRecipients.map((r) => getRecipientType(r)); const types = extraRecipients.map((r) => getRecipientType(r));
if (types.includes(RecipientStatusType.UNSIGNED)) { if (types.includes(RecipientStatusType.UNSIGNED)) {

View file

@ -19,4 +19,7 @@ export const DOCUMENT_AUDIT_LOG_EMAIL_FORMAT = {
[DOCUMENT_EMAIL_TYPE.DOCUMENT_COMPLETED]: { [DOCUMENT_EMAIL_TYPE.DOCUMENT_COMPLETED]: {
description: 'Document completed', description: 'Document completed',
}, },
[DOCUMENT_EMAIL_TYPE.REMINDER]: {
description: 'Signing Reminder',
},
} satisfies Record<keyof typeof DOCUMENT_EMAIL_TYPE, unknown>; } satisfies Record<keyof typeof DOCUMENT_EMAIL_TYPE, unknown>;

View file

@ -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<typeof ZEnvelopeReminderPeriod>;
export type TEnvelopeReminderDurationPeriod = z.infer<typeof ZEnvelopeReminderDurationPeriod>;
export const ZEnvelopeReminderSettings = z.object({
sendAfter: ZEnvelopeReminderPeriod,
repeatEvery: ZEnvelopeReminderPeriod,
});
export type TEnvelopeReminderSettings = z.infer<typeof ZEnvelopeReminderSettings>;
export const DEFAULT_ENVELOPE_REMINDER_SETTINGS: TEnvelopeReminderSettings = {
sendAfter: { unit: 'day', amount: 5 },
repeatEvery: { unit: 'day', amount: 2 },
};
const UNIT_TO_LUXON_KEY: Record<TEnvelopeReminderDurationPeriod['unit'], keyof DurationLikeObject> =
{
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());
};

View file

@ -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 { EXECUTE_WEBHOOK_JOB_DEFINITION } from './definitions/internal/execute-webhook';
import { EXPIRE_RECIPIENTS_SWEEP_JOB_DEFINITION } from './definitions/internal/expire-recipients-sweep'; 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_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_JOB_DEFINITION } from './definitions/internal/seal-document';
import { SEAL_DOCUMENT_SWEEP_JOB_DEFINITION } from './definitions/internal/seal-document-sweep'; 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'; 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, EXECUTE_WEBHOOK_JOB_DEFINITION,
EXPIRE_RECIPIENTS_SWEEP_JOB_DEFINITION, EXPIRE_RECIPIENTS_SWEEP_JOB_DEFINITION,
PROCESS_RECIPIENT_EXPIRED_JOB_DEFINITION, PROCESS_RECIPIENT_EXPIRED_JOB_DEFINITION,
SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION,
PROCESS_SIGNING_REMINDER_JOB_DEFINITION,
CLEANUP_RATE_LIMITS_JOB_DEFINITION, CLEANUP_RATE_LIMITS_JOB_DEFINITION,
SYNC_EMAIL_DOMAINS_JOB_DEFINITION, SYNC_EMAIL_DOMAINS_JOB_DEFINITION,
] as const); ] as const);

View file

@ -22,6 +22,7 @@ import {
RECIPIENT_ROLE_TO_EMAIL_TYPE, RECIPIENT_ROLE_TO_EMAIL_TYPE,
} from '../../../constants/recipient-roles'; } from '../../../constants/recipient-roles';
import { getEmailContext } from '../../../server-only/email/get-email-context'; 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 { DOCUMENT_AUDIT_LOG_TYPE } from '../../../types/document-audit-logs';
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email'; import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
import { createDocumentAuditLogData } from '../../../utils/document-audit-logs'; import { createDocumentAuditLogData } from '../../../utils/document-audit-logs';
@ -206,6 +207,8 @@ export const run = async ({
}); });
} }
const sentAt = new Date();
await io.runTask('update-recipient', async () => { await io.runTask('update-recipient', async () => {
await prisma.recipient.update({ await prisma.recipient.update({
where: { where: {
@ -213,26 +216,33 @@ export const run = async ({
}, },
data: { data: {
sendStatus: SendStatus.SENT, sendStatus: SendStatus.SENT,
sentAt,
}, },
}); });
}); });
await io.runTask('store-audit-log', async () => { // Compute the first reminder time based on the envelope's effective settings.
await prisma.documentAuditLog.create({ await updateRecipientNextReminder({
data: createDocumentAuditLogData({ recipientId: recipient.id,
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT, envelopeId: envelope.id,
envelopeId: envelope.id, sentAt,
user, lastReminderSentAt: null,
requestMetadata, });
data: {
emailType: recipientEmailType, await prisma.documentAuditLog.create({
recipientId: recipient.id, data: createDocumentAuditLogData({
recipientName: recipient.name, type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
recipientEmail: recipient.email, envelopeId: envelope.id,
recipientRole: recipient.role, user,
isResending: false, requestMetadata,
}, data: {
}), emailType: recipientEmailType,
}); recipientId: recipient.id,
recipientName: recipient.name,
recipientEmail: recipient.email,
recipientRole: recipient.role,
isResending: false,
},
}),
}); });
}; };

View file

@ -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,
});
}
};

View file

@ -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
>;

View file

@ -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,
},
});
}),
);
};

View file

@ -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
>;

View file

@ -442,6 +442,7 @@ export const completeDocumentWithToken = async ({
where: { id: nextRecipient.id }, where: { id: nextRecipient.id },
data: { data: {
sendStatus: SendStatus.SENT, sendStatus: SendStatus.SENT,
sentAt: new Date(),
...(nextSigner && envelope.documentMeta?.allowDictateNextSigner ...(nextSigner && envelope.documentMeta?.allowDictateNextSigner
? { ? {
name: nextSigner.name, name: nextSigner.name,

View file

@ -69,6 +69,8 @@ export const viewedDocument = async ({
// This handles cases where distribution is done manually // This handles cases where distribution is done manually
sendStatus: SendStatus.SENT, sendStatus: SendStatus.SENT,
readStatus: ReadStatus.OPENED, 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({ const envelope = await prisma.envelope.findUniqueOrThrow({
where: { where: {
id: recipient.envelopeId, id: recipient.envelopeId,

View file

@ -18,6 +18,7 @@ import {
import { createDocumentAuthOptions, extractDocumentAuthMethods } from '../../utils/document-auth'; import { createDocumentAuthOptions, extractDocumentAuthMethods } from '../../utils/document-auth';
import type { EnvelopeIdOptions } from '../../utils/envelope'; import type { EnvelopeIdOptions } from '../../utils/envelope';
import { buildTeamWhereQuery, canAccessTeamDocument } from '../../utils/teams'; import { buildTeamWhereQuery, canAccessTeamDocument } from '../../utils/teams';
import { recomputeNextReminderForEnvelope } from '../recipient/update-recipient-next-reminder';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { getEnvelopeWhereInput } from './get-envelope-by-id'; import { getEnvelopeWhereInput } from './get-envelope-by-id';
@ -354,6 +355,11 @@ export const updateEnvelope = async ({
return result; 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) { if (envelope.type === EnvelopeType.TEMPLATE) {
await triggerWebhook({ await triggerWebhook({
event: WebhookTriggerEvents.TEMPLATE_UPDATED, event: WebhookTriggerEvents.TEMPLATE_UPDATED,

View file

@ -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<typeof ZEnvelopeReminderSettings.parse> | 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 },
});
}),
);
};

View file

@ -705,6 +705,7 @@ export const createDocumentFromDirectTemplate = async ({
where: { id: nextRecipient.id }, where: { id: nextRecipient.id },
data: { data: {
sendStatus: SendStatus.SENT, sendStatus: SendStatus.SENT,
sentAt: new Date(),
...(nextSigner && documentMeta?.allowDictateNextSigner ...(nextSigner && documentMeta?.allowDictateNextSigner
? { ? {
name: nextSigner.name, name: nextSigner.name,

View file

@ -63,6 +63,7 @@ export const ZDocumentAuditLogEmailTypeSchema = z.enum([
'ASSISTING_REQUEST', 'ASSISTING_REQUEST',
'CC', 'CC',
'DOCUMENT_COMPLETED', 'DOCUMENT_COMPLETED',
'REMINDER',
]); ]);
export const ZDocumentMetaDiffTypeSchema = z.enum([ export const ZDocumentMetaDiffTypeSchema = z.enum([

View file

@ -4,6 +4,7 @@ import { z } from 'zod';
import { VALID_DATE_FORMAT_VALUES } from '@documenso/lib/constants/date-formats'; import { VALID_DATE_FORMAT_VALUES } from '@documenso/lib/constants/date-formats';
import { ZEnvelopeExpirationPeriod } from '@documenso/lib/constants/envelope-expiration'; 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 { SUPPORTED_LANGUAGE_CODES } from '@documenso/lib/constants/i18n';
import { isValidRedirectUrl } from '@documenso/lib/utils/is-valid-redirect-url'; import { isValidRedirectUrl } from '@documenso/lib/utils/is-valid-redirect-url';
import { zEmail } from '@documenso/lib/utils/zod'; import { zEmail } from '@documenso/lib/utils/zod';
@ -131,6 +132,7 @@ export const ZDocumentMetaCreateSchema = z.object({
emailReplyTo: zEmail().nullish(), emailReplyTo: zEmail().nullish(),
emailSettings: ZDocumentEmailSettingsSchema.nullish(), emailSettings: ZDocumentEmailSettingsSchema.nullish(),
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullish(), envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullish(),
reminderSettings: ZEnvelopeReminderSettings.nullish(),
}); });
export type TDocumentMetaCreate = z.infer<typeof ZDocumentMetaCreateSchema>; export type TDocumentMetaCreate = z.infer<typeof ZDocumentMetaCreateSchema>;

View file

@ -72,6 +72,7 @@ export const ZDocumentSchema = LegacyDocumentSchema.pick({
emailId: true, emailId: true,
emailReplyTo: true, emailReplyTo: true,
envelopeExpirationPeriod: true, envelopeExpirationPeriod: true,
reminderSettings: true,
}).extend({ }).extend({
password: z.string().nullable().default(null), password: z.string().nullable().default(null),
documentId: z.number().default(-1).optional(), documentId: z.number().default(-1).optional(),

View file

@ -43,6 +43,7 @@ export const ZEnvelopeEditorSettingsSchema = z.object({
allowConfigureRedirectUrl: z.boolean(), allowConfigureRedirectUrl: z.boolean(),
allowConfigureDistribution: z.boolean(), allowConfigureDistribution: z.boolean(),
allowConfigureExpirationPeriod: z.boolean(), allowConfigureExpirationPeriod: z.boolean(),
allowConfigureReminders: z.boolean(),
allowConfigureEmailSender: z.boolean(), allowConfigureEmailSender: z.boolean(),
allowConfigureEmailReplyTo: z.boolean(), allowConfigureEmailReplyTo: z.boolean(),
}) })
@ -122,6 +123,7 @@ export const DEFAULT_EDITOR_CONFIG: EnvelopeEditorConfig = {
allowConfigureRedirectUrl: true, allowConfigureRedirectUrl: true,
allowConfigureDistribution: true, allowConfigureDistribution: true,
allowConfigureExpirationPeriod: true, allowConfigureExpirationPeriod: true,
allowConfigureReminders: true,
allowConfigureEmailSender: true, allowConfigureEmailSender: true,
allowConfigureEmailReplyTo: true, allowConfigureEmailReplyTo: true,
}, },
@ -180,6 +182,7 @@ export const DEFAULT_EMBEDDED_EDITOR_CONFIG = {
allowConfigureRedirectUrl: true, allowConfigureRedirectUrl: true,
allowConfigureDistribution: true, allowConfigureDistribution: true,
allowConfigureExpirationPeriod: true, allowConfigureExpirationPeriod: true,
allowConfigureReminders: true,
allowConfigureEmailSender: true, allowConfigureEmailSender: true,
allowConfigureEmailReplyTo: true, allowConfigureEmailReplyTo: true,
}, },
@ -271,6 +274,7 @@ export const ZEditorEnvelopeSchema = EnvelopeSchema.pick({
emailId: true, emailId: true,
emailReplyTo: true, emailReplyTo: true,
envelopeExpirationPeriod: true, envelopeExpirationPeriod: true,
reminderSettings: true,
}), }),
recipients: ZEnvelopeRecipientLiteSchema.array(), recipients: ZEnvelopeRecipientLiteSchema.array(),
fields: ZEnvelopeFieldSchema.array(), fields: ZEnvelopeFieldSchema.array(),

View file

@ -118,6 +118,13 @@ export const ZEnvelopeRecipientManySchema = ZRecipientManySchema.omit({
templateId: true, templateId: true,
}); });
export type TRecipientSchema = z.infer<typeof ZRecipientSchema>;
export type TRecipientLite = z.infer<typeof ZRecipientLiteSchema>;
export type TRecipientMany = z.infer<typeof ZRecipientManySchema>;
export type TEnvelopeRecipientSchema = z.infer<typeof ZEnvelopeRecipientSchema>;
export type TEnvelopeRecipientLite = z.infer<typeof ZEnvelopeRecipientLiteSchema>;
export type TEnvelopeRecipientMany = z.infer<typeof ZEnvelopeRecipientManySchema>;
export const ZRecipientEmailSchema = z.union([ export const ZRecipientEmailSchema = z.union([
z.literal(''), z.literal(''),
zEmail('Invalid email').trim().toLowerCase().max(254), zEmail('Invalid email').trim().toLowerCase().max(254),

View file

@ -66,6 +66,9 @@ export const extractDerivedDocumentMeta = (
// Envelope expiration. // Envelope expiration.
envelopeExpirationPeriod: envelopeExpirationPeriod:
meta.envelopeExpirationPeriod ?? settings.envelopeExpirationPeriod ?? null, meta.envelopeExpirationPeriod ?? settings.envelopeExpirationPeriod ?? null,
// Reminder settings.
reminderSettings: meta.reminderSettings ?? settings.reminderSettings ?? null,
} satisfies Omit<DocumentMeta, 'id'>; } satisfies Omit<DocumentMeta, 'id'>;
}; };

View file

@ -67,6 +67,9 @@ export const buildEmbeddedFeatures = (
allowConfigureExpirationPeriod: allowConfigureExpirationPeriod:
features.settings?.allowConfigureExpirationPeriod ?? features.settings?.allowConfigureExpirationPeriod ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureExpirationPeriod, DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureExpirationPeriod,
allowConfigureReminders:
features.settings?.allowConfigureReminders ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureReminders,
allowConfigureEmailSender: allowConfigureEmailSender:
features.settings?.allowConfigureEmailSender ?? features.settings?.allowConfigureEmailSender ??
DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureEmailSender, DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureEmailSender,

View file

@ -252,7 +252,7 @@ export type EnvelopeItemPermissions = {
export const getEnvelopeItemPermissions = ( export const getEnvelopeItemPermissions = (
envelope: Pick<Envelope, 'completedAt' | 'deletedAt' | 'type' | 'status'>, envelope: Pick<Envelope, 'completedAt' | 'deletedAt' | 'type' | 'status'>,
recipients: Recipient[], recipients: Pick<Recipient, 'role' | 'signingStatus' | 'sendStatus'>[],
): EnvelopeItemPermissions => { ): EnvelopeItemPermissions => {
// Always reject completed/rejected/deleted envelopes. // Always reject completed/rejected/deleted envelopes.
if ( if (

View file

@ -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_DOCUMENT_DATE_FORMAT } from '../constants/date-formats';
import { DEFAULT_ENVELOPE_EXPIRATION_PERIOD } from '../constants/envelope-expiration'; import { DEFAULT_ENVELOPE_EXPIRATION_PERIOD } from '../constants/envelope-expiration';
import { DEFAULT_ENVELOPE_REMINDER_SETTINGS } from '../constants/envelope-reminder';
import { import {
LOWEST_ORGANISATION_ROLE, LOWEST_ORGANISATION_ROLE,
ORGANISATION_MEMBER_ROLE_HIERARCHY, ORGANISATION_MEMBER_ROLE_HIERARCHY,
@ -142,6 +143,8 @@ export const generateDefaultOrganisationSettings = (): Omit<
envelopeExpirationPeriod: DEFAULT_ENVELOPE_EXPIRATION_PERIOD, envelopeExpirationPeriod: DEFAULT_ENVELOPE_EXPIRATION_PERIOD,
reminderSettings: DEFAULT_ENVELOPE_REMINDER_SETTINGS,
aiFeaturesEnabled: false, aiFeaturesEnabled: false,
}; };
}; };

View file

@ -1,10 +1,11 @@
import type { Envelope } from '@prisma/client'; import type { Envelope } from '@prisma/client';
import { type Field, type Recipient, RecipientRole, SigningStatus } from '@prisma/client'; import { type Field, RecipientRole, SigningStatus } from '@prisma/client';
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field'; import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app'; import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
import { AppError, AppErrorCode } from '../errors/app-error'; import { AppError, AppErrorCode } from '../errors/app-error';
import type { TRecipientLite } from '../types/recipient';
import { extractLegacyIds } from '../universal/id'; import { extractLegacyIds } from '../universal/id';
import { zEmail } from './zod'; 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. * Currently only SIGNERs are validated - they must have at least one signature field.
*/ */
export const getRecipientsWithMissingFields = <T extends Pick<Recipient, 'id' | 'role'>>( export const getRecipientsWithMissingFields = <T extends Pick<TRecipientLite, 'id' | 'role'>>(
recipients: T[], recipients: T[],
fields: Pick<Field, 'type' | 'recipientId'>[], fields: Pick<Field, 'type' | 'recipientId'>[],
): T[] => { ): T[] => {
@ -42,7 +43,10 @@ export const formatSigningLink = (token: string) => `${NEXT_PUBLIC_WEBAPP_URL()}
/** /**
* Whether a recipient can be modified by the document owner. * Whether a recipient can be modified by the document owner.
*/ */
export const canRecipientBeModified = (recipient: Recipient, fields: Field[]) => { export const canRecipientBeModified = (
recipient: TRecipientLite,
fields: Pick<Field, 'recipientId' | 'inserted'>[],
) => {
if (!recipient) { if (!recipient) {
return false; return false;
} }
@ -72,7 +76,10 @@ export const canRecipientBeModified = (recipient: Recipient, fields: Field[]) =>
* - They are not a Viewer or CCer * - They are not a Viewer or CCer
* - They can be modified (canRecipientBeModified) * - They can be modified (canRecipientBeModified)
*/ */
export const canRecipientFieldsBeModified = (recipient: Recipient, fields: Field[]) => { export const canRecipientFieldsBeModified = (
recipient: TRecipientLite,
fields: Pick<Field, 'recipientId' | 'inserted'>[],
) => {
if (!canRecipientBeModified(recipient, fields)) { if (!canRecipientBeModified(recipient, fields)) {
return false; return false;
} }
@ -81,7 +88,7 @@ export const canRecipientFieldsBeModified = (recipient: Recipient, fields: Field
}; };
export const mapRecipientToLegacyRecipient = ( export const mapRecipientToLegacyRecipient = (
recipient: Recipient, recipient: TRecipientLite,
envelope: Pick<Envelope, 'type' | 'secondaryId'>, envelope: Pick<Envelope, 'type' | 'secondaryId'>,
) => { ) => {
const legacyId = extractLegacyIds(envelope); const legacyId = extractLegacyIds(envelope);
@ -92,6 +99,7 @@ export const mapRecipientToLegacyRecipient = (
}; };
}; };
export const findRecipientByEmail = <T extends { email: string }>({ export const findRecipientByEmail = <T extends { email: string }>({
recipients, recipients,
userEmail, userEmail,
@ -102,7 +110,7 @@ export const findRecipientByEmail = <T extends { email: string }>({
teamEmail?: string | null; teamEmail?: string | null;
}) => recipients.find((r) => r.email === userEmail || (teamEmail && r.email === teamEmail)); }) => recipients.find((r) => r.email === userEmail || (teamEmail && r.email === teamEmail));
export const isRecipientEmailValidForSending = (recipient: Pick<Recipient, 'email'>) => { export const isRecipientEmailValidForSending = (recipient: Pick<TRecipientLite, 'email'>) => {
return zEmail().safeParse(recipient.email).success; return zEmail().safeParse(recipient.email).success;
}; };

View file

@ -208,6 +208,8 @@ export const generateDefaultTeamSettings = (): Omit<TeamGlobalSettings, 'id' | '
envelopeExpirationPeriod: null, envelopeExpirationPeriod: null,
reminderSettings: null,
aiFeaturesEnabled: null, aiFeaturesEnabled: null,
}; };
}; };

View file

@ -0,0 +1,16 @@
-- AlterTable
ALTER TABLE "DocumentMeta" ADD COLUMN "reminderSettings" JSONB;
-- AlterTable
ALTER TABLE "OrganisationGlobalSettings" ADD COLUMN "reminderSettings" JSONB;
-- AlterTable
ALTER TABLE "TeamGlobalSettings" ADD COLUMN "reminderSettings" JSONB;
-- AlterTable
ALTER TABLE "Recipient" ADD COLUMN "sentAt" TIMESTAMP(3),
ADD COLUMN "lastReminderSentAt" TIMESTAMP(3),
ADD COLUMN "nextReminderAt" TIMESTAMP(3);
-- CreateIndex
CREATE INDEX "Recipient_nextReminderAt_idx" ON "Recipient"("nextReminderAt");

View file

@ -507,7 +507,7 @@ enum DocumentDistributionMethod {
NONE NONE
} }
/// @zod.import(["import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';", "import { ZEnvelopeExpirationPeriod as ZEnvelopeExpirationPeriodSchema } from '@documenso/lib/constants/envelope-expiration';"]) /// @zod.import(["import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';", "import { ZEnvelopeExpirationPeriod as ZEnvelopeExpirationPeriodSchema } from '@documenso/lib/constants/envelope-expiration';", "import { ZEnvelopeReminderSettings as ZEnvelopeReminderSettingsSchema } from '@documenso/lib/constants/envelope-reminder';"])
model DocumentMeta { model DocumentMeta {
id String @id @default(cuid()) id String @id @default(cuid())
subject String? subject String?
@ -531,6 +531,8 @@ model DocumentMeta {
envelopeExpirationPeriod Json? /// [EnvelopeExpirationPeriod] @zod.custom.use(ZEnvelopeExpirationPeriodSchema) envelopeExpirationPeriod Json? /// [EnvelopeExpirationPeriod] @zod.custom.use(ZEnvelopeExpirationPeriodSchema)
reminderSettings Json? /// [EnvelopeReminderSettings] @zod.custom.use(ZEnvelopeReminderSettingsSchema)
envelope Envelope? envelope Envelope?
} }
@ -587,7 +589,10 @@ model Recipient {
expired DateTime? // deprecated Not in use. To be removed in a future migration. expired DateTime? // deprecated Not in use. To be removed in a future migration.
expiresAt DateTime? expiresAt DateTime?
expirationNotifiedAt DateTime? expirationNotifiedAt DateTime?
sentAt DateTime?
signedAt DateTime? signedAt DateTime?
lastReminderSentAt DateTime?
nextReminderAt DateTime?
authOptions Json? /// [RecipientAuthOptions] @zod.custom.use(ZRecipientAuthOptionsSchema) authOptions Json? /// [RecipientAuthOptions] @zod.custom.use(ZRecipientAuthOptionsSchema)
signingOrder Int? /// @zod.number.describe("The order in which the recipient should sign the document. Only works if the document is set to sequential signing.") signingOrder Int? /// @zod.number.describe("The order in which the recipient should sign the document. Only works if the document is set to sequential signing.")
rejectionReason String? rejectionReason String?
@ -607,6 +612,7 @@ model Recipient {
@@index([email, documentDeletedAt, envelopeId], map: "Recipient_email_documentDeletedAt_envelopeId_idx") @@index([email, documentDeletedAt, envelopeId], map: "Recipient_email_documentDeletedAt_envelopeId_idx")
@@index([email, envelopeId], map: "Recipient_email_envelopeId_idx") @@index([email, envelopeId], map: "Recipient_email_envelopeId_idx")
@@index([email, signingStatus, envelopeId, role], map: "Recipient_email_signingStatus_envelopeId_role_idx") @@index([email, signingStatus, envelopeId, role], map: "Recipient_email_signingStatus_envelopeId_role_idx")
@@index([nextReminderAt])
@@index([email(ops: raw("gin_trgm_ops"))], map: "Recipient_email_trgm_idx", type: Gin) @@index([email(ops: raw("gin_trgm_ops"))], map: "Recipient_email_trgm_idx", type: Gin)
@@index([name(ops: raw("gin_trgm_ops"))], map: "Recipient_name_trgm_idx", type: Gin) @@index([name(ops: raw("gin_trgm_ops"))], map: "Recipient_name_trgm_idx", type: Gin)
} }
@ -825,7 +831,7 @@ enum OrganisationMemberInviteStatus {
DECLINED DECLINED
} }
/// @zod.import(["import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';", "import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';", "import { ZEnvelopeExpirationPeriod as ZEnvelopeExpirationPeriodSchema } from '@documenso/lib/constants/envelope-expiration';"]) /// @zod.import(["import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';", "import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';", "import { ZEnvelopeExpirationPeriod as ZEnvelopeExpirationPeriodSchema } from '@documenso/lib/constants/envelope-expiration';", "import { ZEnvelopeReminderSettings as ZEnvelopeReminderSettingsSchema } from '@documenso/lib/constants/envelope-reminder';"])
model OrganisationGlobalSettings { model OrganisationGlobalSettings {
id String @id id String @id
organisation Organisation? organisation Organisation?
@ -859,11 +865,13 @@ model OrganisationGlobalSettings {
envelopeExpirationPeriod Json? /// [EnvelopeExpirationPeriod] @zod.custom.use(ZEnvelopeExpirationPeriodSchema) envelopeExpirationPeriod Json? /// [EnvelopeExpirationPeriod] @zod.custom.use(ZEnvelopeExpirationPeriodSchema)
reminderSettings Json? /// [EnvelopeReminderSettings] @zod.custom.use(ZEnvelopeReminderSettingsSchema)
// AI features settings. // AI features settings.
aiFeaturesEnabled Boolean @default(false) aiFeaturesEnabled Boolean @default(false)
} }
/// @zod.import(["import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';", "import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';", "import { ZEnvelopeExpirationPeriod as ZEnvelopeExpirationPeriodSchema } from '@documenso/lib/constants/envelope-expiration';"]) /// @zod.import(["import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';", "import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';", "import { ZEnvelopeExpirationPeriod as ZEnvelopeExpirationPeriodSchema } from '@documenso/lib/constants/envelope-expiration';", "import { ZEnvelopeReminderSettings as ZEnvelopeReminderSettingsSchema } from '@documenso/lib/constants/envelope-reminder';"])
model TeamGlobalSettings { model TeamGlobalSettings {
id String @id id String @id
team Team? team Team?
@ -898,6 +906,8 @@ model TeamGlobalSettings {
envelopeExpirationPeriod Json? /// [EnvelopeExpirationPeriod] @zod.custom.use(ZEnvelopeExpirationPeriodSchema) envelopeExpirationPeriod Json? /// [EnvelopeExpirationPeriod] @zod.custom.use(ZEnvelopeExpirationPeriodSchema)
reminderSettings Json? /// [EnvelopeReminderSettings] @zod.custom.use(ZEnvelopeReminderSettingsSchema)
// AI features settings. // AI features settings.
aiFeaturesEnabled Boolean? aiFeaturesEnabled Boolean?
} }

View file

@ -5,14 +5,14 @@ import { authenticatedProcedure } from '../trpc';
import { import {
ZUpdateDocumentRequestSchema, ZUpdateDocumentRequestSchema,
ZUpdateDocumentResponseSchema, ZUpdateDocumentResponseSchema,
updateDocumentMeta,
} from './update-document.types'; } from './update-document.types';
import { updateDocumentMeta as updateDocumentTrpcMeta } from './update-document.types';
/** /**
* Public route. * Public route.
*/ */
export const updateDocumentRoute = authenticatedProcedure export const updateDocumentRoute = authenticatedProcedure
.meta(updateDocumentTrpcMeta) .meta(updateDocumentMeta)
.input(ZUpdateDocumentRequestSchema) .input(ZUpdateDocumentRequestSchema)
.output(ZUpdateDocumentResponseSchema) .output(ZUpdateDocumentResponseSchema)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {

View file

@ -39,6 +39,7 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
defaultRecipients, defaultRecipients,
delegateDocumentOwnership, delegateDocumentOwnership,
envelopeExpirationPeriod, envelopeExpirationPeriod,
reminderSettings,
// Branding related settings. // Branding related settings.
brandingEnabled, brandingEnabled,
@ -151,6 +152,7 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
delegateDocumentOwnership: derivedDelegateDocumentOwnership, delegateDocumentOwnership: derivedDelegateDocumentOwnership,
envelopeExpirationPeriod: envelopeExpirationPeriod:
envelopeExpirationPeriod === null ? Prisma.DbNull : envelopeExpirationPeriod, envelopeExpirationPeriod === null ? Prisma.DbNull : envelopeExpirationPeriod,
reminderSettings: reminderSettings === null ? Prisma.DbNull : reminderSettings,
// Branding related settings. // Branding related settings.
brandingEnabled, brandingEnabled,

View file

@ -1,6 +1,7 @@
import { z } from 'zod'; import { z } from 'zod';
import { ZEnvelopeExpirationPeriod } from '@documenso/lib/constants/envelope-expiration'; 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 { SUPPORTED_LANGUAGE_CODES } from '@documenso/lib/constants/i18n';
import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients'; import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email'; import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
@ -28,6 +29,7 @@ export const ZUpdateOrganisationSettingsRequestSchema = z.object({
defaultRecipients: ZDefaultRecipientsSchema.nullish(), defaultRecipients: ZDefaultRecipientsSchema.nullish(),
delegateDocumentOwnership: z.boolean().nullish(), delegateDocumentOwnership: z.boolean().nullish(),
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.optional(), envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.optional(),
reminderSettings: ZEnvelopeReminderSettings.optional(),
// Branding related settings. // Branding related settings.
brandingEnabled: z.boolean().optional(), brandingEnabled: z.boolean().optional(),

View file

@ -41,6 +41,7 @@ export const updateTeamSettingsRoute = authenticatedProcedure
drawSignatureEnabled, drawSignatureEnabled,
delegateDocumentOwnership, delegateDocumentOwnership,
envelopeExpirationPeriod, envelopeExpirationPeriod,
reminderSettings,
// Branding related settings. // Branding related settings.
brandingEnabled, brandingEnabled,
@ -157,6 +158,7 @@ export const updateTeamSettingsRoute = authenticatedProcedure
delegateDocumentOwnership, delegateDocumentOwnership,
envelopeExpirationPeriod: envelopeExpirationPeriod:
envelopeExpirationPeriod === null ? Prisma.DbNull : envelopeExpirationPeriod, envelopeExpirationPeriod === null ? Prisma.DbNull : envelopeExpirationPeriod,
reminderSettings: reminderSettings === null ? Prisma.DbNull : reminderSettings,
// Branding related settings. // Branding related settings.
brandingEnabled, brandingEnabled,

View file

@ -1,6 +1,7 @@
import { z } from 'zod'; import { z } from 'zod';
import { ZEnvelopeExpirationPeriod } from '@documenso/lib/constants/envelope-expiration'; 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 { SUPPORTED_LANGUAGE_CODES } from '@documenso/lib/constants/i18n';
import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients'; import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email'; import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
@ -31,6 +32,7 @@ export const ZUpdateTeamSettingsRequestSchema = z.object({
drawSignatureEnabled: z.boolean().nullish(), drawSignatureEnabled: z.boolean().nullish(),
delegateDocumentOwnership: z.boolean().nullish(), delegateDocumentOwnership: z.boolean().nullish(),
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullish(), envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullish(),
reminderSettings: ZEnvelopeReminderSettings.nullish(),
// Branding related settings. // Branding related settings.
brandingEnabled: z.boolean().nullish(), brandingEnabled: z.boolean().nullish(),

View file

@ -2,12 +2,13 @@ import { useState } from 'react';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; 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 { SigningStatus } from '@prisma/client';
import { Clock, EyeOffIcon } from 'lucide-react'; import { Clock, EyeOffIcon } from 'lucide-react';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { isTemplateRecipientEmailPlaceholder } from '@documenso/lib/constants/template'; import { isTemplateRecipientEmailPlaceholder } from '@documenso/lib/constants/template';
import type { TRecipientLite } from '@documenso/lib/types/recipient';
import { parseMessageDescriptor } from '@documenso/lib/utils/i18n'; import { parseMessageDescriptor } from '@documenso/lib/utils/i18n';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter'; import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { FieldRootContainer } from '@documenso/ui/components/field/field'; import { FieldRootContainer } from '@documenso/ui/components/field/field';
@ -34,7 +35,7 @@ const getRecipientDisplayText = (recipient: { name: string; email: string }) =>
}; };
export type DocumentField = Field & { export type DocumentField = Field & {
recipient: Pick<Recipient, 'name' | 'email' | 'signingStatus'>; recipient: Pick<TRecipientLite, 'name' | 'email' | 'signingStatus'>;
}; };
export type DocumentReadOnlyFieldsProps = { export type DocumentReadOnlyFieldsProps = {
@ -68,7 +69,7 @@ export type DocumentReadOnlyFieldsProps = {
export const mapFieldsWithRecipients = ( export const mapFieldsWithRecipients = (
fields: Field[], fields: Field[],
recipients: Recipient[], recipients: TRecipientLite[],
): DocumentField[] => { ): DocumentField[] => {
return fields.map((field) => { return fields.map((field) => {
const recipient = recipients.find((recipient) => recipient.id === field.recipientId) || { const recipient = recipients.find((recipient) => recipient.id === field.recipientId) || {

View file

@ -90,7 +90,7 @@ export const ExpirationPeriodPicker = ({
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Select value={mode} onValueChange={onModeChange} disabled={disabled}> <Select value={mode} onValueChange={onModeChange} disabled={disabled}>
<SelectTrigger className="bg-background"> <SelectTrigger className="bg-background" data-testid="envelope-expiration-mode">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
@ -113,13 +113,14 @@ export const ExpirationPeriodPicker = ({
type="number" type="number"
min={1} min={1}
className="w-20 bg-background" className="w-20 bg-background"
data-testid="envelope-expiration-amount"
value={amount} value={amount}
onChange={(e) => onAmountChange(Number(e.target.value))} onChange={(e) => onAmountChange(Number(e.target.value))}
disabled={disabled} disabled={disabled}
/> />
<Select value={unit} onValueChange={onUnitChange} disabled={disabled}> <Select value={unit} onValueChange={onUnitChange} disabled={disabled}>
<SelectTrigger className="flex-1 bg-background"> <SelectTrigger className="flex-1 bg-background" data-testid="envelope-expiration-unit">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>

View file

@ -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 (
<div className="flex flex-col gap-4" data-testid="reminder-settings-picker">
<Select value={mode} onValueChange={onModeChange} disabled={disabled}>
<SelectTrigger className="bg-background" data-testid="reminder-mode-select">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="enabled">
<Trans>Enabled</Trans>
</SelectItem>
<SelectItem value="disabled">
<Trans>No reminders</Trans>
</SelectItem>
{inheritLabel !== undefined && <SelectItem value="inherit">{inheritLabel}</SelectItem>}
</SelectContent>
</Select>
{mode === 'enabled' && (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label className="text-sm text-muted-foreground">
<Trans>Send first reminder after</Trans>
</Label>
<div className="flex flex-row gap-2">
<Input
type="number"
min={1}
className="w-20 bg-background"
value={sendAfterAmount}
onChange={(e) => updateSendAfter({ amount: Number(e.target.value) })}
disabled={disabled}
data-testid="reminder-send-after-amount"
/>
<UnitSelect
value={sendAfterUnit}
amount={sendAfterAmount}
onChange={(unit) =>
updateSendAfter({
unit: unit as TEnvelopeReminderDurationPeriod['unit'],
})
}
disabled={disabled}
testId="reminder-send-after-unit"
/>
</div>
</div>
<div className="flex flex-col gap-2">
<Label className="text-sm text-muted-foreground">
<Trans>Then repeat every</Trans>
</Label>
<Select value={repeatMode} onValueChange={onRepeatModeChange} disabled={disabled}>
<SelectTrigger className="bg-background" data-testid="reminder-repeat-mode-select">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="enabled">
<Trans>Custom interval</Trans>
</SelectItem>
<SelectItem value="disabled">
<Trans>Don't repeat</Trans>
</SelectItem>
</SelectContent>
</Select>
{repeatMode === 'enabled' && (
<div className="flex flex-row gap-2">
<Input
type="number"
min={1}
className="w-20 bg-background"
value={repeatEveryAmount}
onChange={(e) => updateRepeatEvery({ amount: Number(e.target.value) })}
disabled={disabled}
data-testid="reminder-repeat-amount"
/>
<UnitSelect
value={repeatEveryUnit}
amount={repeatEveryAmount}
onChange={(unit) =>
updateRepeatEvery({
unit: unit as TEnvelopeReminderDurationPeriod['unit'],
})
}
disabled={disabled}
testId="reminder-repeat-unit"
/>
</div>
)}
</div>
</div>
)}
</div>
);
};
const UnitSelect = ({
value,
amount,
onChange,
disabled,
testId,
}: {
value: string;
amount: number;
onChange: (value: string) => void;
disabled: boolean;
testId: string;
}) => (
<Select value={value} onValueChange={onChange} disabled={disabled}>
<SelectTrigger className="flex-1 bg-background" data-testid={testId}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="day">
<Plural value={amount} one="Day" other="Days" />
</SelectItem>
<SelectItem value="week">
<Plural value={amount} one="Week" other="Weeks" />
</SelectItem>
<SelectItem value="month">
<Plural value={amount} one="Month" other="Months" />
</SelectItem>
</SelectContent>
</Select>
);

View file

@ -4,7 +4,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import type { Field, Recipient } from '@prisma/client'; import type { Field } from '@prisma/client';
import { FieldType, Prisma, RecipientRole, SendStatus } from '@prisma/client'; import { FieldType, Prisma, RecipientRole, SendStatus } from '@prisma/client';
import { import {
CalendarDays, CalendarDays,
@ -28,6 +28,7 @@ import {
type TFieldMetaSchema as FieldMeta, type TFieldMetaSchema as FieldMeta,
ZFieldMetaSchema, ZFieldMetaSchema,
} from '@documenso/lib/types/field-meta'; } from '@documenso/lib/types/field-meta';
import type { TRecipientLite } from '@documenso/lib/types/recipient';
import { nanoid } from '@documenso/lib/universal/id'; import { nanoid } from '@documenso/lib/universal/id';
import { ADVANCED_FIELD_TYPES_WITH_OPTIONAL_SETTING } from '@documenso/lib/utils/advanced-fields-helpers'; import { ADVANCED_FIELD_TYPES_WITH_OPTIONAL_SETTING } from '@documenso/lib/utils/advanced-fields-helpers';
import { validateFieldsUninserted } from '@documenso/lib/utils/fields'; import { validateFieldsUninserted } from '@documenso/lib/utils/fields';
@ -83,7 +84,7 @@ export type FieldFormType = {
export type AddFieldsFormProps = { export type AddFieldsFormProps = {
documentFlow: DocumentFlowStep; documentFlow: DocumentFlowStep;
hideRecipients?: boolean; hideRecipients?: boolean;
recipients: Recipient[]; recipients: TRecipientLite[];
fields: Field[]; fields: Field[];
onSubmit: (_data: TAddFieldsFormSchema) => void; onSubmit: (_data: TAddFieldsFormSchema) => void;
onAutoSave: (_data: TAddFieldsFormSchema) => Promise<void>; onAutoSave: (_data: TAddFieldsFormSchema) => Promise<void>;
@ -172,7 +173,7 @@ export const AddFieldsFormPartial = ({
}); });
const [selectedField, setSelectedField] = useState<FieldType | null>(null); const [selectedField, setSelectedField] = useState<FieldType | null>(null);
const [selectedSigner, setSelectedSigner] = useState<Recipient | null>(null); const [selectedSigner, setSelectedSigner] = useState<TRecipientLite | null>(null);
const [lastActiveField, setLastActiveField] = useState<TAddFieldsFormSchema['fields'][0] | null>( const [lastActiveField, setLastActiveField] = useState<TAddFieldsFormSchema['fields'][0] | null>(
null, null,
); );
@ -536,7 +537,7 @@ export const AddFieldsFormPartial = ({
}, [recipients]); }, [recipients]);
const recipientsByRole = useMemo(() => { const recipientsByRole = useMemo(() => {
const recipientsByRole: Record<RecipientRole, Recipient[]> = { const recipientsByRole: Record<RecipientRole, TRecipientLite[]> = {
CC: [], CC: [],
VIEWER: [], VIEWER: [],
SIGNER: [], SIGNER: [],

View file

@ -6,7 +6,6 @@ import {
DocumentStatus, DocumentStatus,
DocumentVisibility, DocumentVisibility,
type Field, type Field,
type Recipient,
SendStatus, SendStatus,
TeamMemberRole, TeamMemberRole,
} from '@prisma/client'; } 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 { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones'; import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
import type { TDocument } from '@documenso/lib/types/document'; 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 { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams'; import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams';
import { import {
@ -74,7 +74,7 @@ import type { DocumentFlowStep } from './types';
export type AddSettingsFormProps = { export type AddSettingsFormProps = {
documentFlow: DocumentFlowStep; documentFlow: DocumentFlowStep;
recipients: Recipient[]; recipients: TRecipientLite[];
fields: Field[]; fields: Field[];
isDocumentPdfLoaded: boolean; isDocumentPdfLoaded: boolean;
document: TDocument; document: TDocument;

View file

@ -6,7 +6,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import type { Field, Recipient } from '@prisma/client'; import type { Field } from '@prisma/client';
import { DocumentSigningOrder, RecipientRole, SendStatus } from '@prisma/client'; import { DocumentSigningOrder, RecipientRole, SendStatus } from '@prisma/client';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { GripVerticalIcon, HelpCircle, Plus, Trash } from 'lucide-react'; 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 { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session'; import { useSession } from '@documenso/lib/client-only/providers/session';
import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth'; import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
import type { TRecipientLite } from '@documenso/lib/types/recipient';
import { nanoid } from '@documenso/lib/universal/id'; import { nanoid } from '@documenso/lib/universal/id';
import { canRecipientBeModified as utilCanRecipientBeModified } from '@documenso/lib/utils/recipients'; import { canRecipientBeModified as utilCanRecipientBeModified } from '@documenso/lib/utils/recipients';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
@ -54,12 +55,12 @@ import { SigningOrderConfirmation } from './signing-order-confirmation';
import type { DocumentFlowStep } from './types'; import type { DocumentFlowStep } from './types';
type AutoSaveResponse = { type AutoSaveResponse = {
recipients: Recipient[]; recipients: TRecipientLite[];
}; };
export type AddSignersFormProps = { export type AddSignersFormProps = {
documentFlow: DocumentFlowStep; documentFlow: DocumentFlowStep;
recipients: Recipient[]; recipients: TRecipientLite[];
fields: Field[]; fields: Field[];
signingOrder?: DocumentSigningOrder | null; signingOrder?: DocumentSigningOrder | null;
allowDictateNextSigner?: boolean; allowDictateNextSigner?: boolean;

View file

@ -4,7 +4,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import type { Field, Recipient } from '@prisma/client'; import type { Field } from '@prisma/client';
import { DocumentDistributionMethod, DocumentStatus, RecipientRole } from '@prisma/client'; import { DocumentDistributionMethod, DocumentStatus, RecipientRole } from '@prisma/client';
import { AnimatePresence, motion } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion';
import { InfoIcon } from 'lucide-react'; 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 { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import type { TDocument } from '@documenso/lib/types/document'; import type { TDocument } from '@documenso/lib/types/document';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email'; import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import type { TRecipientLite } from '@documenso/lib/types/recipient';
import { formatSigningLink } from '@documenso/lib/utils/recipients'; import { formatSigningLink } from '@documenso/lib/utils/recipients';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper'; import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper';
@ -59,7 +60,7 @@ import type { DocumentFlowStep } from './types';
export type AddSubjectFormProps = { export type AddSubjectFormProps = {
documentFlow: DocumentFlowStep; documentFlow: DocumentFlowStep;
recipients: Recipient[]; recipients: TRecipientLite[];
fields: Field[]; fields: Field[];
document: TDocument; document: TDocument;
onSubmit: (_data: TAddSubjectFormSchema) => void; onSubmit: (_data: TAddSubjectFormSchema) => void;

View file

@ -2,12 +2,12 @@ import { useCallback, useMemo, useState } from 'react';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import type { Recipient } from '@prisma/client';
import { RecipientRole, SendStatus, SigningStatus } from '@prisma/client'; import { RecipientRole, SendStatus, SigningStatus } from '@prisma/client';
import { Check, ChevronsUpDown, Info } from 'lucide-react'; import { Check, ChevronsUpDown, Info } from 'lucide-react';
import { sortBy } from 'remeda'; import { sortBy } from 'remeda';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; 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 { getRecipientColorStyles } from '../lib/recipient-colors';
import { cn } from '../lib/utils'; import { cn } from '../lib/utils';
@ -18,9 +18,9 @@ import { Tooltip, TooltipContent, TooltipTrigger } from './tooltip';
export interface RecipientSelectorProps { export interface RecipientSelectorProps {
className?: string; className?: string;
selectedRecipient: Recipient | null; selectedRecipient: TRecipientLite | null;
onSelectedRecipientChange: (recipient: Recipient) => void; onSelectedRecipientChange: (recipient: TRecipientLite) => void;
recipients: Recipient[]; recipients: TRecipientLite[];
align?: 'center' | 'end' | 'start'; align?: 'center' | 'end' | 'start';
} }
@ -35,7 +35,7 @@ export const RecipientSelector = ({
const [showRecipientsSelector, setShowRecipientsSelector] = useState(false); const [showRecipientsSelector, setShowRecipientsSelector] = useState(false);
const recipientsByRole = useMemo(() => { const recipientsByRole = useMemo(() => {
const recipientsWithRole: Record<RecipientRole, Recipient[]> = { const recipientsWithRole: Record<RecipientRole, TRecipientLite[]> = {
CC: [], CC: [],
VIEWER: [], VIEWER: [],
SIGNER: [], SIGNER: [],
@ -67,12 +67,12 @@ export const RecipientSelector = ({
[(r) => r.signingOrder || Number.MAX_SAFE_INTEGER, 'asc'], [(r) => r.signingOrder || Number.MAX_SAFE_INTEGER, 'asc'],
[(r) => r.id, 'asc'], [(r) => r.id, 'asc'],
), ),
] as [RecipientRole, Recipient[]], ] as [RecipientRole, TRecipientLite[]],
); );
}, [recipientsByRole]); }, [recipientsByRole]);
const getRecipientLabel = useCallback( const getRecipientLabel = useCallback(
(recipient: Recipient) => { (recipient: TRecipientLite) => {
if (recipient.name && recipient.email) { if (recipient.name && recipient.email) {
return `${recipient.name} (${recipient.email})`; return `${recipient.name} (${recipient.email})`;
} }

View file

@ -4,7 +4,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import type { Field, Recipient } from '@prisma/client'; import type { Field } from '@prisma/client';
import { FieldType, RecipientRole, SendStatus } from '@prisma/client'; import { FieldType, RecipientRole, SendStatus } from '@prisma/client';
import { import {
CalendarDays, CalendarDays,
@ -31,6 +31,7 @@ import {
type TFieldMetaSchema as FieldMeta, type TFieldMetaSchema as FieldMeta,
ZFieldMetaSchema, ZFieldMetaSchema,
} from '@documenso/lib/types/field-meta'; } from '@documenso/lib/types/field-meta';
import type { TRecipientLite } from '@documenso/lib/types/recipient';
import { nanoid } from '@documenso/lib/universal/id'; import { nanoid } from '@documenso/lib/universal/id';
import { ADVANCED_FIELD_TYPES_WITH_OPTIONAL_SETTING } from '@documenso/lib/utils/advanced-fields-helpers'; import { ADVANCED_FIELD_TYPES_WITH_OPTIONAL_SETTING } from '@documenso/lib/utils/advanced-fields-helpers';
import { parseMessageDescriptor } from '@documenso/lib/utils/i18n'; import { parseMessageDescriptor } from '@documenso/lib/utils/i18n';
@ -75,7 +76,7 @@ const DEFAULT_WIDTH_PX = MIN_WIDTH_PX * 2.5;
export type AddTemplateFieldsFormProps = { export type AddTemplateFieldsFormProps = {
documentFlow: DocumentFlowStep; documentFlow: DocumentFlowStep;
recipients: Recipient[]; recipients: TRecipientLite[];
fields: Field[]; fields: Field[];
onSubmit: (_data: TAddTemplateFieldsFormSchema) => void; onSubmit: (_data: TAddTemplateFieldsFormSchema) => void;
onAutoSave: (_data: TAddTemplateFieldsFormSchema) => Promise<void>; onAutoSave: (_data: TAddTemplateFieldsFormSchema) => Promise<void>;
@ -154,7 +155,7 @@ export const AddTemplateFieldsFormPartial = ({
}); });
const [selectedField, setSelectedField] = useState<FieldType | null>(null); const [selectedField, setSelectedField] = useState<FieldType | null>(null);
const [selectedSigner, setSelectedSigner] = useState<Recipient | null>(null); const [selectedSigner, setSelectedSigner] = useState<TRecipientLite | null>(null);
const [showRecipientsSelector, setShowRecipientsSelector] = useState(false); const [showRecipientsSelector, setShowRecipientsSelector] = useState(false);
const selectedSignerIndex = recipients.findIndex((r) => r.id === selectedSigner?.id); const selectedSignerIndex = recipients.findIndex((r) => r.id === selectedSigner?.id);
@ -491,7 +492,7 @@ export const AddTemplateFieldsFormPartial = ({
}, [recipients]); }, [recipients]);
const recipientsByRole = useMemo(() => { const recipientsByRole = useMemo(() => {
const recipientsByRole: Record<RecipientRole, Recipient[]> = { const recipientsByRole: Record<RecipientRole, TRecipientLite[]> = {
CC: [], CC: [],
VIEWER: [], VIEWER: [],
SIGNER: [], SIGNER: [],
@ -520,7 +521,7 @@ export const AddTemplateFieldsFormPartial = ({
const recipientsByRoleToDisplay = useMemo(() => { const recipientsByRoleToDisplay = useMemo(() => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions // 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]) =>
role !== RecipientRole.CC && role !== RecipientRole.CC &&
role !== RecipientRole.VIEWER && role !== RecipientRole.VIEWER &&

View file

@ -7,7 +7,7 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import type { TemplateDirectLink } from '@prisma/client'; 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 { motion } from 'framer-motion';
import { GripVerticalIcon, HelpCircle, Link2Icon, Plus, Trash } from 'lucide-react'; import { GripVerticalIcon, HelpCircle, Link2Icon, Plus, Trash } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form'; 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 { useSession } from '@documenso/lib/client-only/providers/session';
import { isTemplateRecipientEmailPlaceholder } from '@documenso/lib/constants/template'; import { isTemplateRecipientEmailPlaceholder } from '@documenso/lib/constants/template';
import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth'; import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
import type { TRecipientLite } from '@documenso/lib/types/recipient';
import { nanoid } from '@documenso/lib/universal/id'; import { nanoid } from '@documenso/lib/universal/id';
import { generateRecipientPlaceholder } from '@documenso/lib/utils/templates'; import { generateRecipientPlaceholder } from '@documenso/lib/utils/templates';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out'; 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'; import { ZAddTemplatePlacholderRecipientsFormSchema } from './add-template-placeholder-recipients.types';
type AutoSaveResponse = { type AutoSaveResponse = {
recipients: Recipient[]; recipients: TRecipientLite[];
}; };
export type AddTemplatePlaceholderRecipientsFormProps = { export type AddTemplatePlaceholderRecipientsFormProps = {
documentFlow: DocumentFlowStep; documentFlow: DocumentFlowStep;
recipients: Recipient[]; recipients: TRecipientLite[];
fields: Field[]; fields: Field[];
signingOrder?: DocumentSigningOrder | null; signingOrder?: DocumentSigningOrder | null;
allowDictateNextSigner?: boolean; allowDictateNextSigner?: boolean;

View file

@ -3,7 +3,7 @@ import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro'; import { Trans, useLingui } from '@lingui/react/macro';
import { DocumentVisibility, TeamMemberRole, TemplateType } from '@prisma/client'; 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 { InfoIcon } from 'lucide-react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern'; 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 { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email'; import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import type { TDocumentMetaDateFormat } from '@documenso/lib/types/document-meta'; 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 type { TTemplate } from '@documenso/lib/types/template';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth'; import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams'; import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams';
@ -81,7 +82,7 @@ import { ZAddTemplateSettingsFormSchema } from './add-template-settings.types';
export type AddTemplateSettingsFormProps = { export type AddTemplateSettingsFormProps = {
documentFlow: DocumentFlowStep; documentFlow: DocumentFlowStep;
recipients: Recipient[]; recipients: TRecipientLite[];
fields: Field[]; fields: Field[];
isDocumentPdfLoaded: boolean; isDocumentPdfLoaded: boolean;
template: TTemplate; template: TTemplate;