From 4935f387bfa2163b8d95443cd85172dfa84bdddd Mon Sep 17 00:00:00 2001 From: Ephraim Duncan <55143799+ephraimduncan@users.noreply.github.com> Date: Tue, 14 Apr 2026 11:01:53 +0000 Subject: [PATCH 1/7] feat: signing reminders (#1749) --- README.md | 3 + .../dialogs/document-resend-dialog.tsx | 9 +- .../dialogs/envelope-redistribute-dialog.tsx | 5 +- .../envelope-save-as-template-dialog.tsx | 9 +- .../dialogs/template-direct-link-dialog.tsx | 5 +- .../dialogs/template-use-dialog.tsx | 5 +- .../embed/authoring/configure-fields-view.tsx | 7 +- .../forms/document-preferences-form.tsx | 38 +++ .../general/avatar-with-recipient.tsx | 4 +- .../document/document-page-view-dropdown.tsx | 22 +- .../document-recipient-link-copy-dialog.tsx | 10 +- .../envelope-editor-settings-dialog.tsx | 58 +++- .../envelope-recipient-selector.tsx | 29 +- .../general/stack-avatars-with-tooltip.tsx | 15 +- .../app/components/general/stack-avatars.tsx | 9 +- .../template-page-view-recipients.tsx | 4 +- .../documents-table-action-dropdown.tsx | 22 +- .../templates-table-action-dropdown.tsx | 3 +- .../o.$orgUrl.settings.document.tsx | 2 + .../t.$teamUrl+/settings.document.tsx | 2 + .../envelope-settings.spec.ts | 92 ++++++ .../envelope-expiration-settings.spec.ts | 21 +- .../template-document-reminder.tsx | 77 +++++ .../email/templates/document-reminder.tsx | 91 ++++++ .../client-only/hooks/use-editor-fields.ts | 4 +- .../hooks/use-editor-recipients.ts | 4 +- packages/lib/client-only/recipient-type.ts | 6 +- packages/lib/constants/document-audit-logs.ts | 3 + packages/lib/constants/envelope-reminder.ts | 86 ++++++ packages/lib/jobs/client.ts | 4 + .../emails/send-signing-email.handler.ts | 44 +-- .../process-signing-reminder.handler.ts | 222 +++++++++++++++ .../internal/process-signing-reminder.ts | 31 ++ .../send-signing-reminders-sweep.handler.ts | 49 ++++ .../internal/send-signing-reminders-sweep.ts | 30 ++ .../document/complete-document-with-token.ts | 1 + .../server-only/document/viewed-document.ts | 5 + .../server-only/envelope/update-envelope.ts | 6 + .../update-recipient-next-reminder.ts | 112 ++++++++ .../create-document-from-direct-template.ts | 1 + packages/lib/types/document-audit-logs.ts | 1 + packages/lib/types/document-meta.ts | 2 + packages/lib/types/document.ts | 1 + packages/lib/types/envelope-editor.ts | 4 + packages/lib/types/recipient.ts | 7 + packages/lib/utils/document.ts | 3 + packages/lib/utils/embed-config.ts | 3 + packages/lib/utils/envelope.ts | 2 +- packages/lib/utils/organisations.ts | 3 + packages/lib/utils/recipients.ts | 20 +- packages/lib/utils/teams.ts | 2 + .../migration.sql | 16 ++ packages/prisma/schema.prisma | 16 +- .../server/document-router/update-document.ts | 4 +- .../update-organisation-settings.ts | 2 + .../update-organisation-settings.types.ts | 2 + .../team-router/update-team-settings.ts | 2 + .../team-router/update-team-settings.types.ts | 2 + .../document/document-read-only-fields.tsx | 7 +- .../document/expiration-period-picker.tsx | 5 +- .../document/reminder-settings-picker.tsx | 266 ++++++++++++++++++ .../primitives/document-flow/add-fields.tsx | 9 +- .../primitives/document-flow/add-settings.tsx | 4 +- .../primitives/document-flow/add-signers.tsx | 7 +- .../primitives/document-flow/add-subject.tsx | 5 +- packages/ui/primitives/recipient-selector.tsx | 14 +- .../template-flow/add-template-fields.tsx | 11 +- .../add-template-placeholder-recipients.tsx | 7 +- .../template-flow/add-template-settings.tsx | 5 +- 69 files changed, 1426 insertions(+), 156 deletions(-) create mode 100644 packages/email/template-components/template-document-reminder.tsx create mode 100644 packages/email/templates/document-reminder.tsx create mode 100644 packages/lib/constants/envelope-reminder.ts create mode 100644 packages/lib/jobs/definitions/internal/process-signing-reminder.handler.ts create mode 100644 packages/lib/jobs/definitions/internal/process-signing-reminder.ts create mode 100644 packages/lib/jobs/definitions/internal/send-signing-reminders-sweep.handler.ts create mode 100644 packages/lib/jobs/definitions/internal/send-signing-reminders-sweep.ts create mode 100644 packages/lib/server-only/recipient/update-recipient-next-reminder.ts create mode 100644 packages/prisma/migrations/20260401000000_add_reminder_settings/migration.sql create mode 100644 packages/ui/components/document/reminder-settings-picker.tsx diff --git a/README.md b/README.md index 9021ee7e6..b0fc9990f 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,9 @@ git clone https://github.com//documenso - Optional: Seed the database using `npm run prisma:seed -w @documenso/prisma` to create a test user and document. - Optional: Create your own signing certificate. - To generate your own using these steps and a Linux Terminal or Windows Subsystem for Linux (WSL), see **[Create your own signing certificate](./SIGNING.md)**. +- Optional: Configure job provider for document reminders. + - The default local job provider does not support scheduled jobs required for document reminders. + - To enable reminders, set `NEXT_PRIVATE_JOBS_PROVIDER=inngest` and provide `NEXT_PRIVATE_INNGEST_EVENT_KEY` in your `.env` file. ### Run in Gitpod diff --git a/apps/remix/app/components/dialogs/document-resend-dialog.tsx b/apps/remix/app/components/dialogs/document-resend-dialog.tsx index d8c0a73ee..57e5235cc 100644 --- a/apps/remix/app/components/dialogs/document-resend-dialog.tsx +++ b/apps/remix/app/components/dialogs/document-resend-dialog.tsx @@ -4,13 +4,14 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; -import { type Recipient, SigningStatus, type Team, type User } from '@prisma/client'; +import { SigningStatus, type Team, type User } from '@prisma/client'; import { History } from 'lucide-react'; import { useForm, useWatch } from 'react-hook-form'; import * as z from 'zod'; import { useSession } from '@documenso/lib/client-only/providers/session'; import { getRecipientType } from '@documenso/lib/client-only/recipient-type'; +import type { TRecipientLite } from '@documenso/lib/types/recipient'; import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; import type { Document } from '@documenso/prisma/types/document-legacy-schema'; import { trpc as trpcReact } from '@documenso/trpc/react'; @@ -45,10 +46,10 @@ const FORM_ID = 'resend-email'; export type DocumentResendDialogProps = { document: Pick & { user: Pick; - recipients: Recipient[]; + recipients: TRecipientLite[]; team: Pick | null; }; - recipients: Recipient[]; + recipients: TRecipientLite[]; }; export const ZResendDocumentFormSchema = z.object({ @@ -183,7 +184,7 @@ export const DocumentResendDialog = ({ document, recipients }: DocumentResendDia + + + + ); +}; + +export default TemplateDocumentReminder; diff --git a/packages/email/templates/document-reminder.tsx b/packages/email/templates/document-reminder.tsx new file mode 100644 index 000000000..c12c8f327 --- /dev/null +++ b/packages/email/templates/document-reminder.tsx @@ -0,0 +1,91 @@ +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { RecipientRole } from '@prisma/client'; + +import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; + +import { Body, Container, Head, Hr, Html, Img, Preview, Section, Text } from '../components'; +import { useBranding } from '../providers/branding'; +import { TemplateCustomMessageBody } from '../template-components/template-custom-message-body'; +import { TemplateDocumentReminder } from '../template-components/template-document-reminder'; +import { TemplateFooter } from '../template-components/template-footer'; + +export type DocumentReminderEmailTemplateProps = { + recipientName: string; + documentName: string; + signDocumentLink: string; + assetBaseUrl?: string; + customBody?: string; + role: RecipientRole; +}; + +export const DocumentReminderEmailTemplate = ({ + recipientName = 'John Doe', + documentName = 'Open Source Pledge.pdf', + signDocumentLink = 'https://documenso.com', + assetBaseUrl = 'http://localhost:3002', + customBody, + role = RecipientRole.SIGNER, +}: DocumentReminderEmailTemplateProps) => { + const { _ } = useLingui(); + const branding = useBranding(); + + const action = _(RECIPIENT_ROLES_DESCRIPTION[role].actionVerb).toLowerCase(); + + const previewText = msg`Reminder to ${action} ${documentName}`; + + const getAssetUrl = (path: string) => { + return new URL(path, assetBaseUrl).toString(); + }; + + return ( + + + {_(previewText)} + + +
+ +
+ {branding.brandingEnabled && branding.brandingLogo ? ( + Branding Logo + ) : ( + Documenso Logo + )} + + +
+
+ + {customBody && ( + +
+ + + +
+
+ )} + +
+ + + + +
+ + + ); +}; + +export default DocumentReminderEmailTemplate; diff --git a/packages/lib/client-only/hooks/use-editor-fields.ts b/packages/lib/client-only/hooks/use-editor-fields.ts index 6edb951c5..c326f6332 100644 --- a/packages/lib/client-only/hooks/use-editor-fields.ts +++ b/packages/lib/client-only/hooks/use-editor-fields.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; -import type { Field, Recipient } from '@prisma/client'; +import type { Field } from '@prisma/client'; import { FieldType } from '@prisma/client'; import { useFieldArray, useForm } from 'react-hook-form'; import { z } from 'zod'; @@ -61,7 +61,7 @@ type UseEditorFieldsResponse = { getFieldsByRecipient: (recipientId: number) => TLocalField[]; // Selected recipient - selectedRecipient: Recipient | null; + selectedRecipient: TEditorEnvelope['recipients'][number] | null; setSelectedRecipient: (recipientId: number | null) => void; resetForm: (fields?: Field[]) => void; diff --git a/packages/lib/client-only/hooks/use-editor-recipients.ts b/packages/lib/client-only/hooks/use-editor-recipients.ts index dfea75acc..a7fbadbd7 100644 --- a/packages/lib/client-only/hooks/use-editor-recipients.ts +++ b/packages/lib/client-only/hooks/use-editor-recipients.ts @@ -1,7 +1,7 @@ import { useId } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; -import { DocumentSigningOrder, type Recipient, RecipientRole } from '@prisma/client'; +import { DocumentSigningOrder, RecipientRole } from '@prisma/client'; import type { UseFormReturn } from 'react-hook-form'; import { useForm } from 'react-hook-form'; import { prop, sortBy } from 'remeda'; @@ -39,7 +39,7 @@ type EditorRecipientsProps = { }; type ResetFormOptions = { - recipients?: Recipient[]; + recipients?: TEditorEnvelope['recipients']; documentMeta?: TEditorEnvelope['documentMeta']; }; diff --git a/packages/lib/client-only/recipient-type.ts b/packages/lib/client-only/recipient-type.ts index 1701fc1be..0476264ff 100644 --- a/packages/lib/client-only/recipient-type.ts +++ b/packages/lib/client-only/recipient-type.ts @@ -7,6 +7,8 @@ import { SigningStatus, } from '@prisma/client'; +type RecipientForType = Pick; + export enum RecipientStatusType { COMPLETED = 'completed', OPENED = 'opened', @@ -16,7 +18,7 @@ export enum RecipientStatusType { } export const getRecipientType = ( - recipient: Recipient, + recipient: RecipientForType, distributionMethod: DocumentDistributionMethod = DocumentDistributionMethod.EMAIL, ) => { if (recipient.role === RecipientRole.CC || recipient.signingStatus === SigningStatus.SIGNED) { @@ -45,7 +47,7 @@ export const getRecipientType = ( return RecipientStatusType.UNSIGNED; }; -export const getExtraRecipientsType = (extraRecipients: Recipient[]) => { +export const getExtraRecipientsType = (extraRecipients: RecipientForType[]) => { const types = extraRecipients.map((r) => getRecipientType(r)); if (types.includes(RecipientStatusType.UNSIGNED)) { diff --git a/packages/lib/constants/document-audit-logs.ts b/packages/lib/constants/document-audit-logs.ts index 9b91d2cb9..3ec716e0a 100644 --- a/packages/lib/constants/document-audit-logs.ts +++ b/packages/lib/constants/document-audit-logs.ts @@ -19,4 +19,7 @@ export const DOCUMENT_AUDIT_LOG_EMAIL_FORMAT = { [DOCUMENT_EMAIL_TYPE.DOCUMENT_COMPLETED]: { description: 'Document completed', }, + [DOCUMENT_EMAIL_TYPE.REMINDER]: { + description: 'Signing Reminder', + }, } satisfies Record; diff --git a/packages/lib/constants/envelope-reminder.ts b/packages/lib/constants/envelope-reminder.ts new file mode 100644 index 000000000..8f2672d66 --- /dev/null +++ b/packages/lib/constants/envelope-reminder.ts @@ -0,0 +1,86 @@ +import type { DurationLikeObject } from 'luxon'; +import { Duration } from 'luxon'; +import { z } from 'zod'; + +export const ZEnvelopeReminderDurationPeriod = z.object({ + unit: z.enum(['day', 'week', 'month']), + amount: z.number().int().min(1), +}); + +export const ZEnvelopeReminderDisabledPeriod = z.object({ + disabled: z.literal(true), +}); + +export const ZEnvelopeReminderPeriod = z.union([ + ZEnvelopeReminderDurationPeriod, + ZEnvelopeReminderDisabledPeriod, +]); + +export type TEnvelopeReminderPeriod = z.infer; +export type TEnvelopeReminderDurationPeriod = z.infer; + +export const ZEnvelopeReminderSettings = z.object({ + sendAfter: ZEnvelopeReminderPeriod, + repeatEvery: ZEnvelopeReminderPeriod, +}); + +export type TEnvelopeReminderSettings = z.infer; + +export const DEFAULT_ENVELOPE_REMINDER_SETTINGS: TEnvelopeReminderSettings = { + sendAfter: { unit: 'day', amount: 5 }, + repeatEvery: { unit: 'day', amount: 2 }, +}; + +const UNIT_TO_LUXON_KEY: Record = + { + day: 'days', + week: 'weeks', + month: 'months', + }; + +export const getEnvelopeReminderDuration = (period: TEnvelopeReminderDurationPeriod): Duration => { + return Duration.fromObject({ [UNIT_TO_LUXON_KEY[period.unit]]: period.amount }); +}; + +/** + * Resolve the next reminder timestamp from the config and the last reminder sent time. + * + * - `null` config means reminders are disabled (inherit = no override, resolved as disabled). + * - `{ sendAfter: { disabled: true }, ... }` means never send the first reminder. + * - `{ repeatEvery: { disabled: true }, ... }` means don't repeat after the first reminder. + * + * `sentAt` is when the signing request was sent to this specific recipient. + * + * Returns the next Date the reminder should be sent, or null if no reminder should be sent. + */ +export const resolveNextReminderAt = (options: { + config: TEnvelopeReminderSettings | null; + sentAt: Date; + lastReminderSentAt: Date | null; +}): Date | null => { + const { config, sentAt, lastReminderSentAt } = options; + + if (!config) { + return null; + } + + // If we haven't sent the first reminder yet, use sendAfter. + if (!lastReminderSentAt) { + if ('disabled' in config.sendAfter) { + return null; + } + + const delay = getEnvelopeReminderDuration(config.sendAfter); + + return new Date(sentAt.getTime() + delay.toMillis()); + } + + // For subsequent reminders, use repeatEvery. + if ('disabled' in config.repeatEvery) { + return null; + } + + const interval = getEnvelopeReminderDuration(config.repeatEvery); + + return new Date(lastReminderSentAt.getTime() + interval.toMillis()); +}; diff --git a/packages/lib/jobs/client.ts b/packages/lib/jobs/client.ts index b9f5c6368..d0b2c4f98 100644 --- a/packages/lib/jobs/client.ts +++ b/packages/lib/jobs/client.ts @@ -16,8 +16,10 @@ import { CLEANUP_RATE_LIMITS_JOB_DEFINITION } from './definitions/internal/clean import { EXECUTE_WEBHOOK_JOB_DEFINITION } from './definitions/internal/execute-webhook'; import { EXPIRE_RECIPIENTS_SWEEP_JOB_DEFINITION } from './definitions/internal/expire-recipients-sweep'; import { PROCESS_RECIPIENT_EXPIRED_JOB_DEFINITION } from './definitions/internal/process-recipient-expired'; +import { PROCESS_SIGNING_REMINDER_JOB_DEFINITION } from './definitions/internal/process-signing-reminder'; import { SEAL_DOCUMENT_JOB_DEFINITION } from './definitions/internal/seal-document'; import { SEAL_DOCUMENT_SWEEP_JOB_DEFINITION } from './definitions/internal/seal-document-sweep'; +import { SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION } from './definitions/internal/send-signing-reminders-sweep'; import { SYNC_EMAIL_DOMAINS_JOB_DEFINITION } from './definitions/internal/sync-email-domains'; /** @@ -43,6 +45,8 @@ export const jobsClient = new JobClient([ EXECUTE_WEBHOOK_JOB_DEFINITION, EXPIRE_RECIPIENTS_SWEEP_JOB_DEFINITION, PROCESS_RECIPIENT_EXPIRED_JOB_DEFINITION, + SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION, + PROCESS_SIGNING_REMINDER_JOB_DEFINITION, CLEANUP_RATE_LIMITS_JOB_DEFINITION, SYNC_EMAIL_DOMAINS_JOB_DEFINITION, ] as const); diff --git a/packages/lib/jobs/definitions/emails/send-signing-email.handler.ts b/packages/lib/jobs/definitions/emails/send-signing-email.handler.ts index 17f099afa..f02e36673 100644 --- a/packages/lib/jobs/definitions/emails/send-signing-email.handler.ts +++ b/packages/lib/jobs/definitions/emails/send-signing-email.handler.ts @@ -22,6 +22,7 @@ import { RECIPIENT_ROLE_TO_EMAIL_TYPE, } from '../../../constants/recipient-roles'; import { getEmailContext } from '../../../server-only/email/get-email-context'; +import { updateRecipientNextReminder } from '../../../server-only/recipient/update-recipient-next-reminder'; import { DOCUMENT_AUDIT_LOG_TYPE } from '../../../types/document-audit-logs'; import { extractDerivedDocumentEmailSettings } from '../../../types/document-email'; import { createDocumentAuditLogData } from '../../../utils/document-audit-logs'; @@ -206,6 +207,8 @@ export const run = async ({ }); } + const sentAt = new Date(); + await io.runTask('update-recipient', async () => { await prisma.recipient.update({ where: { @@ -213,26 +216,33 @@ export const run = async ({ }, data: { sendStatus: SendStatus.SENT, + sentAt, }, }); }); - await io.runTask('store-audit-log', async () => { - await prisma.documentAuditLog.create({ - data: createDocumentAuditLogData({ - type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT, - envelopeId: envelope.id, - user, - requestMetadata, - data: { - emailType: recipientEmailType, - recipientId: recipient.id, - recipientName: recipient.name, - recipientEmail: recipient.email, - recipientRole: recipient.role, - isResending: false, - }, - }), - }); + // Compute the first reminder time based on the envelope's effective settings. + await updateRecipientNextReminder({ + recipientId: recipient.id, + envelopeId: envelope.id, + sentAt, + lastReminderSentAt: null, + }); + + await prisma.documentAuditLog.create({ + data: createDocumentAuditLogData({ + type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT, + envelopeId: envelope.id, + user, + requestMetadata, + data: { + emailType: recipientEmailType, + recipientId: recipient.id, + recipientName: recipient.name, + recipientEmail: recipient.email, + recipientRole: recipient.role, + isResending: false, + }, + }), }); }; diff --git a/packages/lib/jobs/definitions/internal/process-signing-reminder.handler.ts b/packages/lib/jobs/definitions/internal/process-signing-reminder.handler.ts new file mode 100644 index 000000000..94bffad9d --- /dev/null +++ b/packages/lib/jobs/definitions/internal/process-signing-reminder.handler.ts @@ -0,0 +1,222 @@ +import { createElement } from 'react'; + +import { msg } from '@lingui/core/macro'; +import { + DocumentDistributionMethod, + DocumentStatus, + OrganisationType, + RecipientRole, + SendStatus, + SigningStatus, + WebhookTriggerEvents, +} from '@prisma/client'; + +import { mailer } from '@documenso/email/mailer'; +import DocumentReminderEmailTemplate from '@documenso/email/templates/document-reminder'; +import { prisma } from '@documenso/prisma'; + +import { getI18nInstance } from '../../../client-only/providers/i18n-server'; +import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app'; +import { RECIPIENT_ROLES_DESCRIPTION } from '../../../constants/recipient-roles'; +import { getEmailContext } from '../../../server-only/email/get-email-context'; +import { updateRecipientNextReminder } from '../../../server-only/recipient/update-recipient-next-reminder'; +import { triggerWebhook } from '../../../server-only/webhooks/trigger/trigger-webhook'; +import { DOCUMENT_AUDIT_LOG_TYPE, DOCUMENT_EMAIL_TYPE } from '../../../types/document-audit-logs'; +import { extractDerivedDocumentEmailSettings } from '../../../types/document-email'; +import { + ZWebhookDocumentSchema, + mapEnvelopeToWebhookDocumentPayload, +} from '../../../types/webhook-payload'; +import { createDocumentAuditLogData } from '../../../utils/document-audit-logs'; +import { renderCustomEmailTemplate } from '../../../utils/render-custom-email-template'; +import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n'; +import type { JobRunIO } from '../../client/_internal/job'; +import type { TProcessSigningReminderJobDefinition } from './process-signing-reminder'; + +export const run = async ({ + payload, + io, +}: { + payload: TProcessSigningReminderJobDefinition; + io: JobRunIO; +}) => { + const { recipientId } = payload; + const now = new Date(); + + // Atomically claim this reminder by setting lastReminderSentAt and clearing + // nextReminderAt so no other sweep picks it up. + const updatedCount = await prisma.recipient.updateMany({ + where: { + id: recipientId, + signingStatus: SigningStatus.NOT_SIGNED, + sendStatus: SendStatus.SENT, + role: { not: RecipientRole.CC }, + envelope: { + status: DocumentStatus.PENDING, + deletedAt: null, + }, + }, + data: { + lastReminderSentAt: now, + nextReminderAt: null, + }, + }); + + if (updatedCount.count === 0) { + io.logger.info(`Recipient ${recipientId} no longer eligible for reminder, skipping`); + return; + } + + const recipient = await prisma.recipient.findFirst({ + where: { id: recipientId }, + include: { + envelope: { + include: { + documentMeta: true, + user: true, + recipients: true, + team: { + select: { + name: true, + }, + }, + }, + }, + }, + }); + + if (!recipient) { + io.logger.warn(`Recipient ${recipientId} not found`); + return; + } + + const { envelope } = recipient; + + if (!envelope.documentMeta) { + io.logger.warn(`Envelope ${envelope.id} missing documentMeta`); + return; + } + + // Skip if distribution method is NONE (manual link sharing, no emails). + if (envelope.documentMeta.distributionMethod === DocumentDistributionMethod.NONE) { + io.logger.info(`Envelope ${envelope.id} uses manual distribution, skipping email reminder`); + return; + } + + if (!extractDerivedDocumentEmailSettings(envelope.documentMeta).recipientSigningRequest) { + io.logger.info(`Envelope ${envelope.id} has email signing requests disabled, skipping`); + return; + } + + const { branding, emailLanguage, organisationType, senderEmail, replyToEmail } = + await getEmailContext({ + emailType: 'RECIPIENT', + source: { + type: 'team', + teamId: envelope.teamId, + }, + meta: envelope.documentMeta, + }); + + const i18n = await getI18nInstance(emailLanguage); + + const recipientActionVerb = i18n + ._(RECIPIENT_ROLES_DESCRIPTION[recipient.role].actionVerb) + .toLowerCase(); + + let emailSubject = i18n._( + msg`Reminder: Please ${recipientActionVerb} the document "${envelope.title}"`, + ); + + if (organisationType === OrganisationType.ORGANISATION) { + emailSubject = i18n._( + msg`Reminder: ${envelope.team.name} invited you to ${recipientActionVerb} a document`, + ); + } + + const customEmailTemplate = { + 'signer.name': recipient.name, + 'signer.email': recipient.email, + 'document.name': envelope.title, + }; + + if (envelope.documentMeta.subject) { + emailSubject = renderCustomEmailTemplate( + i18n._(msg`Reminder: ${envelope.documentMeta.subject}`), + customEmailTemplate, + ); + } + + const emailMessage = envelope.documentMeta.message + ? renderCustomEmailTemplate(envelope.documentMeta.message, customEmailTemplate) + : undefined; + + const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000'; + const signDocumentLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`; + + io.logger.info( + `Sending signing reminder for envelope ${envelope.id} to recipient ${recipient.id} (${recipient.email})`, + ); + + const template = createElement(DocumentReminderEmailTemplate, { + recipientName: recipient.name, + documentName: envelope.title, + assetBaseUrl, + signDocumentLink, + customBody: emailMessage, + role: recipient.role, + }); + + const [html, text] = await Promise.all([ + renderEmailWithI18N(template, { lang: emailLanguage, branding }), + renderEmailWithI18N(template, { + lang: emailLanguage, + branding, + plainText: true, + }), + ]); + + await mailer.sendMail({ + to: { + name: recipient.name, + address: recipient.email, + }, + from: senderEmail, + replyTo: replyToEmail, + subject: emailSubject, + html, + text, + }); + + await prisma.documentAuditLog.create({ + data: createDocumentAuditLogData({ + type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT, + envelopeId: envelope.id, + data: { + recipientEmail: recipient.email, + recipientName: recipient.name, + recipientId: recipient.id, + recipientRole: recipient.role, + emailType: DOCUMENT_EMAIL_TYPE.REMINDER, + isResending: false, + }, + }), + }); + + await triggerWebhook({ + event: WebhookTriggerEvents.DOCUMENT_REMINDER_SENT, + data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelope)), + userId: envelope.userId, + teamId: envelope.teamId, + }); + + // Compute the next reminder time (repeat interval). + if (recipient.sentAt) { + await updateRecipientNextReminder({ + recipientId: recipient.id, + envelopeId: envelope.id, + sentAt: recipient.sentAt, + lastReminderSentAt: now, + }); + } +}; diff --git a/packages/lib/jobs/definitions/internal/process-signing-reminder.ts b/packages/lib/jobs/definitions/internal/process-signing-reminder.ts new file mode 100644 index 000000000..aacc720a2 --- /dev/null +++ b/packages/lib/jobs/definitions/internal/process-signing-reminder.ts @@ -0,0 +1,31 @@ +import { z } from 'zod'; + +import { type JobDefinition } from '../../client/_internal/job'; + +const PROCESS_SIGNING_REMINDER_JOB_DEFINITION_ID = 'internal.process-signing-reminder'; + +const PROCESS_SIGNING_REMINDER_JOB_DEFINITION_SCHEMA = z.object({ + recipientId: z.number(), +}); + +export type TProcessSigningReminderJobDefinition = z.infer< + typeof PROCESS_SIGNING_REMINDER_JOB_DEFINITION_SCHEMA +>; + +export const PROCESS_SIGNING_REMINDER_JOB_DEFINITION = { + id: PROCESS_SIGNING_REMINDER_JOB_DEFINITION_ID, + name: 'Process Signing Reminder', + version: '1.0.0', + trigger: { + name: PROCESS_SIGNING_REMINDER_JOB_DEFINITION_ID, + schema: PROCESS_SIGNING_REMINDER_JOB_DEFINITION_SCHEMA, + }, + handler: async ({ payload, io }) => { + const handler = await import('./process-signing-reminder.handler'); + + await handler.run({ payload, io }); + }, +} as const satisfies JobDefinition< + typeof PROCESS_SIGNING_REMINDER_JOB_DEFINITION_ID, + TProcessSigningReminderJobDefinition +>; diff --git a/packages/lib/jobs/definitions/internal/send-signing-reminders-sweep.handler.ts b/packages/lib/jobs/definitions/internal/send-signing-reminders-sweep.handler.ts new file mode 100644 index 000000000..e28aa0139 --- /dev/null +++ b/packages/lib/jobs/definitions/internal/send-signing-reminders-sweep.handler.ts @@ -0,0 +1,49 @@ +import { DocumentStatus, RecipientRole, SendStatus, SigningStatus } from '@prisma/client'; + +import { prisma } from '@documenso/prisma'; + +import { jobs } from '../../client'; +import type { JobRunIO } from '../../client/_internal/job'; +import type { TSendSigningRemindersSweepJobDefinition } from './send-signing-reminders-sweep'; + +export const run = async ({ + io, +}: { + payload: TSendSigningRemindersSweepJobDefinition; + io: JobRunIO; +}) => { + const now = new Date(); + + const recipients = await prisma.recipient.findMany({ + where: { + nextReminderAt: { lte: now }, + signingStatus: SigningStatus.NOT_SIGNED, + sendStatus: SendStatus.SENT, + role: { not: RecipientRole.CC }, + envelope: { + status: DocumentStatus.PENDING, + deletedAt: null, + }, + }, + select: { id: true }, + take: 1000, + }); + + if (recipients.length === 0) { + io.logger.info('No recipients need signing reminders'); + return; + } + + io.logger.info(`Found ${recipients.length} recipients needing signing reminders`); + + await Promise.allSettled( + recipients.map(async (recipient) => { + await jobs.triggerJob({ + name: 'internal.process-signing-reminder', + payload: { + recipientId: recipient.id, + }, + }); + }), + ); +}; diff --git a/packages/lib/jobs/definitions/internal/send-signing-reminders-sweep.ts b/packages/lib/jobs/definitions/internal/send-signing-reminders-sweep.ts new file mode 100644 index 000000000..1c12ad19f --- /dev/null +++ b/packages/lib/jobs/definitions/internal/send-signing-reminders-sweep.ts @@ -0,0 +1,30 @@ +import { z } from 'zod'; + +import { type JobDefinition } from '../../client/_internal/job'; + +const SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION_ID = 'internal.send-signing-reminders-sweep'; + +const SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION_SCHEMA = z.object({}); + +export type TSendSigningRemindersSweepJobDefinition = z.infer< + typeof SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION_SCHEMA +>; + +export const SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION = { + id: SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION_ID, + name: 'Send Signing Reminders Sweep', + version: '1.0.0', + trigger: { + name: SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION_ID, + schema: SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION_SCHEMA, + cron: '*/15 * * * *', // Every 15 minutes. + }, + handler: async ({ payload, io }) => { + const handler = await import('./send-signing-reminders-sweep.handler'); + + await handler.run({ payload, io }); + }, +} as const satisfies JobDefinition< + typeof SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION_ID, + TSendSigningRemindersSweepJobDefinition +>; diff --git a/packages/lib/server-only/document/complete-document-with-token.ts b/packages/lib/server-only/document/complete-document-with-token.ts index e8f78a673..a9030ff4f 100644 --- a/packages/lib/server-only/document/complete-document-with-token.ts +++ b/packages/lib/server-only/document/complete-document-with-token.ts @@ -442,6 +442,7 @@ export const completeDocumentWithToken = async ({ where: { id: nextRecipient.id }, data: { sendStatus: SendStatus.SENT, + sentAt: new Date(), ...(nextSigner && envelope.documentMeta?.allowDictateNextSigner ? { name: nextSigner.name, diff --git a/packages/lib/server-only/document/viewed-document.ts b/packages/lib/server-only/document/viewed-document.ts index 5bdd1828b..23aa6d710 100644 --- a/packages/lib/server-only/document/viewed-document.ts +++ b/packages/lib/server-only/document/viewed-document.ts @@ -69,6 +69,8 @@ export const viewedDocument = async ({ // This handles cases where distribution is done manually sendStatus: SendStatus.SENT, readStatus: ReadStatus.OPENED, + // Only set sentAt if not already set (email may have been sent before they opened). + ...(!recipient.sentAt ? { sentAt: new Date() } : {}), }, }); @@ -92,6 +94,9 @@ export const viewedDocument = async ({ }); }); + // Don't schedule reminders for manually distributed documents — + // there's no email pathway to send them through. + const envelope = await prisma.envelope.findUniqueOrThrow({ where: { id: recipient.envelopeId, diff --git a/packages/lib/server-only/envelope/update-envelope.ts b/packages/lib/server-only/envelope/update-envelope.ts index 0abbd19c9..b4e0a8d45 100644 --- a/packages/lib/server-only/envelope/update-envelope.ts +++ b/packages/lib/server-only/envelope/update-envelope.ts @@ -18,6 +18,7 @@ import { import { createDocumentAuthOptions, extractDocumentAuthMethods } from '../../utils/document-auth'; import type { EnvelopeIdOptions } from '../../utils/envelope'; import { buildTeamWhereQuery, canAccessTeamDocument } from '../../utils/teams'; +import { recomputeNextReminderForEnvelope } from '../recipient/update-recipient-next-reminder'; import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; import { getEnvelopeWhereInput } from './get-envelope-by-id'; @@ -354,6 +355,11 @@ export const updateEnvelope = async ({ return result; }); + // Recompute reminders for active recipients when reminder settings change. + if (meta && 'reminderSettings' in meta) { + await recomputeNextReminderForEnvelope(envelope.id); + } + if (envelope.type === EnvelopeType.TEMPLATE) { await triggerWebhook({ event: WebhookTriggerEvents.TEMPLATE_UPDATED, diff --git a/packages/lib/server-only/recipient/update-recipient-next-reminder.ts b/packages/lib/server-only/recipient/update-recipient-next-reminder.ts new file mode 100644 index 000000000..5530580bb --- /dev/null +++ b/packages/lib/server-only/recipient/update-recipient-next-reminder.ts @@ -0,0 +1,112 @@ +import { + DocumentDistributionMethod, + RecipientRole, + SendStatus, + SigningStatus, +} from '@prisma/client'; + +import { prisma } from '@documenso/prisma'; + +import { + ZEnvelopeReminderSettings, + resolveNextReminderAt, +} from '../../constants/envelope-reminder'; + +/** + * Compute and store `nextReminderAt` for a single recipient. + * + * Call this after: + * - Sending the signing email (sentAt is set) + * - Sending a reminder (lastReminderSentAt is updated) + * + * If `reminderSettings` is provided it's used directly, avoiding a query. + * Otherwise it's read from the envelope's documentMeta (already resolved + * from the org/team cascade at envelope creation time). + */ +export const updateRecipientNextReminder = async (options: { + recipientId: number; + envelopeId: string; + sentAt: Date; + lastReminderSentAt: Date | null; + reminderSettings?: ReturnType | null; +}) => { + const { recipientId, envelopeId, sentAt, lastReminderSentAt } = options; + + let settings = options.reminderSettings; + + if (settings === undefined) { + const envelope = await prisma.envelope.findFirst({ + where: { id: envelopeId }, + select: { documentMeta: { select: { reminderSettings: true } } }, + }); + + settings = envelope?.documentMeta?.reminderSettings + ? ZEnvelopeReminderSettings.parse(envelope.documentMeta.reminderSettings) + : null; + } + + const nextReminderAt = resolveNextReminderAt({ + config: settings, + sentAt, + lastReminderSentAt, + }); + + await prisma.recipient.update({ + where: { id: recipientId }, + data: { nextReminderAt }, + }); +}; + +/** + * Recompute `nextReminderAt` for all active (unsigned, sent) recipients + * of a given envelope. Call when document-level reminder settings change. + */ +export const recomputeNextReminderForEnvelope = async (envelopeId: string) => { + const envelope = await prisma.envelope.findFirst({ + where: { id: envelopeId }, + select: { + documentMeta: { + select: { reminderSettings: true, distributionMethod: true }, + }, + }, + }); + + // No reminders for manually distributed documents. + const isEmailDistribution = + envelope?.documentMeta?.distributionMethod !== DocumentDistributionMethod.NONE; + + const settings = + isEmailDistribution && envelope?.documentMeta?.reminderSettings + ? ZEnvelopeReminderSettings.parse(envelope.documentMeta.reminderSettings) + : null; + + const recipients = await prisma.recipient.findMany({ + where: { + envelopeId, + signingStatus: SigningStatus.NOT_SIGNED, + sendStatus: SendStatus.SENT, + sentAt: { not: null }, + role: { not: RecipientRole.CC }, + }, + select: { id: true, sentAt: true, lastReminderSentAt: true }, + }); + + await Promise.all( + recipients.map(async (recipient) => { + if (!recipient.sentAt) { + return; + } + + const nextReminderAt = resolveNextReminderAt({ + config: settings, + sentAt: recipient.sentAt, + lastReminderSentAt: recipient.lastReminderSentAt, + }); + + await prisma.recipient.update({ + where: { id: recipient.id }, + data: { nextReminderAt }, + }); + }), + ); +}; diff --git a/packages/lib/server-only/template/create-document-from-direct-template.ts b/packages/lib/server-only/template/create-document-from-direct-template.ts index 59b1ec910..0cb2cd2e9 100644 --- a/packages/lib/server-only/template/create-document-from-direct-template.ts +++ b/packages/lib/server-only/template/create-document-from-direct-template.ts @@ -705,6 +705,7 @@ export const createDocumentFromDirectTemplate = async ({ where: { id: nextRecipient.id }, data: { sendStatus: SendStatus.SENT, + sentAt: new Date(), ...(nextSigner && documentMeta?.allowDictateNextSigner ? { name: nextSigner.name, diff --git a/packages/lib/types/document-audit-logs.ts b/packages/lib/types/document-audit-logs.ts index 5a1f21a83..eafa1723f 100644 --- a/packages/lib/types/document-audit-logs.ts +++ b/packages/lib/types/document-audit-logs.ts @@ -63,6 +63,7 @@ export const ZDocumentAuditLogEmailTypeSchema = z.enum([ 'ASSISTING_REQUEST', 'CC', 'DOCUMENT_COMPLETED', + 'REMINDER', ]); export const ZDocumentMetaDiffTypeSchema = z.enum([ diff --git a/packages/lib/types/document-meta.ts b/packages/lib/types/document-meta.ts index f3ad49def..759a6fdc8 100644 --- a/packages/lib/types/document-meta.ts +++ b/packages/lib/types/document-meta.ts @@ -4,6 +4,7 @@ import { z } from 'zod'; import { VALID_DATE_FORMAT_VALUES } from '@documenso/lib/constants/date-formats'; import { ZEnvelopeExpirationPeriod } from '@documenso/lib/constants/envelope-expiration'; +import { ZEnvelopeReminderSettings } from '@documenso/lib/constants/envelope-reminder'; import { SUPPORTED_LANGUAGE_CODES } from '@documenso/lib/constants/i18n'; import { isValidRedirectUrl } from '@documenso/lib/utils/is-valid-redirect-url'; import { zEmail } from '@documenso/lib/utils/zod'; @@ -131,6 +132,7 @@ export const ZDocumentMetaCreateSchema = z.object({ emailReplyTo: zEmail().nullish(), emailSettings: ZDocumentEmailSettingsSchema.nullish(), envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullish(), + reminderSettings: ZEnvelopeReminderSettings.nullish(), }); export type TDocumentMetaCreate = z.infer; diff --git a/packages/lib/types/document.ts b/packages/lib/types/document.ts index 125cae519..56bc75568 100644 --- a/packages/lib/types/document.ts +++ b/packages/lib/types/document.ts @@ -72,6 +72,7 @@ export const ZDocumentSchema = LegacyDocumentSchema.pick({ emailId: true, emailReplyTo: true, envelopeExpirationPeriod: true, + reminderSettings: true, }).extend({ password: z.string().nullable().default(null), documentId: z.number().default(-1).optional(), diff --git a/packages/lib/types/envelope-editor.ts b/packages/lib/types/envelope-editor.ts index 02cfa4abd..a8b8787e5 100644 --- a/packages/lib/types/envelope-editor.ts +++ b/packages/lib/types/envelope-editor.ts @@ -43,6 +43,7 @@ export const ZEnvelopeEditorSettingsSchema = z.object({ allowConfigureRedirectUrl: z.boolean(), allowConfigureDistribution: z.boolean(), allowConfigureExpirationPeriod: z.boolean(), + allowConfigureReminders: z.boolean(), allowConfigureEmailSender: z.boolean(), allowConfigureEmailReplyTo: z.boolean(), }) @@ -122,6 +123,7 @@ export const DEFAULT_EDITOR_CONFIG: EnvelopeEditorConfig = { allowConfigureRedirectUrl: true, allowConfigureDistribution: true, allowConfigureExpirationPeriod: true, + allowConfigureReminders: true, allowConfigureEmailSender: true, allowConfigureEmailReplyTo: true, }, @@ -180,6 +182,7 @@ export const DEFAULT_EMBEDDED_EDITOR_CONFIG = { allowConfigureRedirectUrl: true, allowConfigureDistribution: true, allowConfigureExpirationPeriod: true, + allowConfigureReminders: true, allowConfigureEmailSender: true, allowConfigureEmailReplyTo: true, }, @@ -271,6 +274,7 @@ export const ZEditorEnvelopeSchema = EnvelopeSchema.pick({ emailId: true, emailReplyTo: true, envelopeExpirationPeriod: true, + reminderSettings: true, }), recipients: ZEnvelopeRecipientLiteSchema.array(), fields: ZEnvelopeFieldSchema.array(), diff --git a/packages/lib/types/recipient.ts b/packages/lib/types/recipient.ts index 93ce73f02..a15233761 100644 --- a/packages/lib/types/recipient.ts +++ b/packages/lib/types/recipient.ts @@ -118,6 +118,13 @@ export const ZEnvelopeRecipientManySchema = ZRecipientManySchema.omit({ templateId: true, }); +export type TRecipientSchema = z.infer; +export type TRecipientLite = z.infer; +export type TRecipientMany = z.infer; +export type TEnvelopeRecipientSchema = z.infer; +export type TEnvelopeRecipientLite = z.infer; +export type TEnvelopeRecipientMany = z.infer; + export const ZRecipientEmailSchema = z.union([ z.literal(''), zEmail('Invalid email').trim().toLowerCase().max(254), diff --git a/packages/lib/utils/document.ts b/packages/lib/utils/document.ts index 58753a6c8..eaf63d268 100644 --- a/packages/lib/utils/document.ts +++ b/packages/lib/utils/document.ts @@ -66,6 +66,9 @@ export const extractDerivedDocumentMeta = ( // Envelope expiration. envelopeExpirationPeriod: meta.envelopeExpirationPeriod ?? settings.envelopeExpirationPeriod ?? null, + + // Reminder settings. + reminderSettings: meta.reminderSettings ?? settings.reminderSettings ?? null, } satisfies Omit; }; diff --git a/packages/lib/utils/embed-config.ts b/packages/lib/utils/embed-config.ts index d3f44b72e..5456d471e 100644 --- a/packages/lib/utils/embed-config.ts +++ b/packages/lib/utils/embed-config.ts @@ -67,6 +67,9 @@ export const buildEmbeddedFeatures = ( allowConfigureExpirationPeriod: features.settings?.allowConfigureExpirationPeriod ?? DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureExpirationPeriod, + allowConfigureReminders: + features.settings?.allowConfigureReminders ?? + DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureReminders, allowConfigureEmailSender: features.settings?.allowConfigureEmailSender ?? DEFAULT_EMBEDDED_EDITOR_CONFIG.settings.allowConfigureEmailSender, diff --git a/packages/lib/utils/envelope.ts b/packages/lib/utils/envelope.ts index 090e77f6f..0f0aba1c3 100644 --- a/packages/lib/utils/envelope.ts +++ b/packages/lib/utils/envelope.ts @@ -252,7 +252,7 @@ export type EnvelopeItemPermissions = { export const getEnvelopeItemPermissions = ( envelope: Pick, - recipients: Recipient[], + recipients: Pick[], ): EnvelopeItemPermissions => { // Always reject completed/rejected/deleted envelopes. if ( diff --git a/packages/lib/utils/organisations.ts b/packages/lib/utils/organisations.ts index b38e9a697..e4eb814f3 100644 --- a/packages/lib/utils/organisations.ts +++ b/packages/lib/utils/organisations.ts @@ -9,6 +9,7 @@ import type { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/orga import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../constants/date-formats'; import { DEFAULT_ENVELOPE_EXPIRATION_PERIOD } from '../constants/envelope-expiration'; +import { DEFAULT_ENVELOPE_REMINDER_SETTINGS } from '../constants/envelope-reminder'; import { LOWEST_ORGANISATION_ROLE, ORGANISATION_MEMBER_ROLE_HIERARCHY, @@ -142,6 +143,8 @@ export const generateDefaultOrganisationSettings = (): Omit< envelopeExpirationPeriod: DEFAULT_ENVELOPE_EXPIRATION_PERIOD, + reminderSettings: DEFAULT_ENVELOPE_REMINDER_SETTINGS, + aiFeaturesEnabled: false, }; }; diff --git a/packages/lib/utils/recipients.ts b/packages/lib/utils/recipients.ts index a8a4c574f..ce7314f42 100644 --- a/packages/lib/utils/recipients.ts +++ b/packages/lib/utils/recipients.ts @@ -1,10 +1,11 @@ import type { Envelope } from '@prisma/client'; -import { type Field, type Recipient, RecipientRole, SigningStatus } from '@prisma/client'; +import { type Field, RecipientRole, SigningStatus } from '@prisma/client'; import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field'; import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app'; import { AppError, AppErrorCode } from '../errors/app-error'; +import type { TRecipientLite } from '../types/recipient'; import { extractLegacyIds } from '../universal/id'; import { zEmail } from './zod'; @@ -20,7 +21,7 @@ export const RECIPIENT_ROLES_THAT_REQUIRE_FIELDS = [RecipientRole.SIGNER] as con * * Currently only SIGNERs are validated - they must have at least one signature field. */ -export const getRecipientsWithMissingFields = >( +export const getRecipientsWithMissingFields = >( recipients: T[], fields: Pick[], ): T[] => { @@ -42,7 +43,10 @@ export const formatSigningLink = (token: string) => `${NEXT_PUBLIC_WEBAPP_URL()} /** * Whether a recipient can be modified by the document owner. */ -export const canRecipientBeModified = (recipient: Recipient, fields: Field[]) => { +export const canRecipientBeModified = ( + recipient: TRecipientLite, + fields: Pick[], +) => { if (!recipient) { return false; } @@ -72,7 +76,10 @@ export const canRecipientBeModified = (recipient: Recipient, fields: Field[]) => * - They are not a Viewer or CCer * - They can be modified (canRecipientBeModified) */ -export const canRecipientFieldsBeModified = (recipient: Recipient, fields: Field[]) => { +export const canRecipientFieldsBeModified = ( + recipient: TRecipientLite, + fields: Pick[], +) => { if (!canRecipientBeModified(recipient, fields)) { return false; } @@ -81,7 +88,7 @@ export const canRecipientFieldsBeModified = (recipient: Recipient, fields: Field }; export const mapRecipientToLegacyRecipient = ( - recipient: Recipient, + recipient: TRecipientLite, envelope: Pick, ) => { const legacyId = extractLegacyIds(envelope); @@ -92,6 +99,7 @@ export const mapRecipientToLegacyRecipient = ( }; }; + export const findRecipientByEmail = ({ recipients, userEmail, @@ -102,7 +110,7 @@ export const findRecipientByEmail = ({ teamEmail?: string | null; }) => recipients.find((r) => r.email === userEmail || (teamEmail && r.email === teamEmail)); -export const isRecipientEmailValidForSending = (recipient: Pick) => { +export const isRecipientEmailValidForSending = (recipient: Pick) => { return zEmail().safeParse(recipient.email).success; }; diff --git a/packages/lib/utils/teams.ts b/packages/lib/utils/teams.ts index 02fc562c1..ba71da07a 100644 --- a/packages/lib/utils/teams.ts +++ b/packages/lib/utils/teams.ts @@ -208,6 +208,8 @@ export const generateDefaultTeamSettings = (): Omit { diff --git a/packages/trpc/server/organisation-router/update-organisation-settings.ts b/packages/trpc/server/organisation-router/update-organisation-settings.ts index 7dfa9b033..570187cf8 100644 --- a/packages/trpc/server/organisation-router/update-organisation-settings.ts +++ b/packages/trpc/server/organisation-router/update-organisation-settings.ts @@ -39,6 +39,7 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure defaultRecipients, delegateDocumentOwnership, envelopeExpirationPeriod, + reminderSettings, // Branding related settings. brandingEnabled, @@ -151,6 +152,7 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure delegateDocumentOwnership: derivedDelegateDocumentOwnership, envelopeExpirationPeriod: envelopeExpirationPeriod === null ? Prisma.DbNull : envelopeExpirationPeriod, + reminderSettings: reminderSettings === null ? Prisma.DbNull : reminderSettings, // Branding related settings. brandingEnabled, diff --git a/packages/trpc/server/organisation-router/update-organisation-settings.types.ts b/packages/trpc/server/organisation-router/update-organisation-settings.types.ts index bc211a93c..daf93d821 100644 --- a/packages/trpc/server/organisation-router/update-organisation-settings.types.ts +++ b/packages/trpc/server/organisation-router/update-organisation-settings.types.ts @@ -1,6 +1,7 @@ import { z } from 'zod'; import { ZEnvelopeExpirationPeriod } from '@documenso/lib/constants/envelope-expiration'; +import { ZEnvelopeReminderSettings } from '@documenso/lib/constants/envelope-reminder'; import { SUPPORTED_LANGUAGE_CODES } from '@documenso/lib/constants/i18n'; import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients'; import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email'; @@ -28,6 +29,7 @@ export const ZUpdateOrganisationSettingsRequestSchema = z.object({ defaultRecipients: ZDefaultRecipientsSchema.nullish(), delegateDocumentOwnership: z.boolean().nullish(), envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.optional(), + reminderSettings: ZEnvelopeReminderSettings.optional(), // Branding related settings. brandingEnabled: z.boolean().optional(), diff --git a/packages/trpc/server/team-router/update-team-settings.ts b/packages/trpc/server/team-router/update-team-settings.ts index f764bc95d..b44db128a 100644 --- a/packages/trpc/server/team-router/update-team-settings.ts +++ b/packages/trpc/server/team-router/update-team-settings.ts @@ -41,6 +41,7 @@ export const updateTeamSettingsRoute = authenticatedProcedure drawSignatureEnabled, delegateDocumentOwnership, envelopeExpirationPeriod, + reminderSettings, // Branding related settings. brandingEnabled, @@ -157,6 +158,7 @@ export const updateTeamSettingsRoute = authenticatedProcedure delegateDocumentOwnership, envelopeExpirationPeriod: envelopeExpirationPeriod === null ? Prisma.DbNull : envelopeExpirationPeriod, + reminderSettings: reminderSettings === null ? Prisma.DbNull : reminderSettings, // Branding related settings. brandingEnabled, diff --git a/packages/trpc/server/team-router/update-team-settings.types.ts b/packages/trpc/server/team-router/update-team-settings.types.ts index 0ba4bebed..e8f24e611 100644 --- a/packages/trpc/server/team-router/update-team-settings.types.ts +++ b/packages/trpc/server/team-router/update-team-settings.types.ts @@ -1,6 +1,7 @@ import { z } from 'zod'; import { ZEnvelopeExpirationPeriod } from '@documenso/lib/constants/envelope-expiration'; +import { ZEnvelopeReminderSettings } from '@documenso/lib/constants/envelope-reminder'; import { SUPPORTED_LANGUAGE_CODES } from '@documenso/lib/constants/i18n'; import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients'; import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email'; @@ -31,6 +32,7 @@ export const ZUpdateTeamSettingsRequestSchema = z.object({ drawSignatureEnabled: z.boolean().nullish(), delegateDocumentOwnership: z.boolean().nullish(), envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullish(), + reminderSettings: ZEnvelopeReminderSettings.nullish(), // Branding related settings. brandingEnabled: z.boolean().nullish(), diff --git a/packages/ui/components/document/document-read-only-fields.tsx b/packages/ui/components/document/document-read-only-fields.tsx index 6fb855a2b..818d3ea25 100644 --- a/packages/ui/components/document/document-read-only-fields.tsx +++ b/packages/ui/components/document/document-read-only-fields.tsx @@ -2,12 +2,13 @@ import { useState } from 'react'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; -import type { DocumentMeta, Field, Recipient } from '@prisma/client'; +import type { DocumentMeta, Field } from '@prisma/client'; import { SigningStatus } from '@prisma/client'; import { Clock, EyeOffIcon } from 'lucide-react'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { isTemplateRecipientEmailPlaceholder } from '@documenso/lib/constants/template'; +import type { TRecipientLite } from '@documenso/lib/types/recipient'; import { parseMessageDescriptor } from '@documenso/lib/utils/i18n'; import { extractInitials } from '@documenso/lib/utils/recipient-formatter'; import { FieldRootContainer } from '@documenso/ui/components/field/field'; @@ -34,7 +35,7 @@ const getRecipientDisplayText = (recipient: { name: string; email: string }) => }; export type DocumentField = Field & { - recipient: Pick; + recipient: Pick; }; export type DocumentReadOnlyFieldsProps = { @@ -68,7 +69,7 @@ export type DocumentReadOnlyFieldsProps = { export const mapFieldsWithRecipients = ( fields: Field[], - recipients: Recipient[], + recipients: TRecipientLite[], ): DocumentField[] => { return fields.map((field) => { const recipient = recipients.find((recipient) => recipient.id === field.recipientId) || { diff --git a/packages/ui/components/document/expiration-period-picker.tsx b/packages/ui/components/document/expiration-period-picker.tsx index ee1c39263..1d2347560 100644 --- a/packages/ui/components/document/expiration-period-picker.tsx +++ b/packages/ui/components/document/expiration-period-picker.tsx @@ -90,7 +90,7 @@ export const ExpirationPeriodPicker = ({ return (
- + diff --git a/packages/ui/components/document/reminder-settings-picker.tsx b/packages/ui/components/document/reminder-settings-picker.tsx new file mode 100644 index 000000000..3c2b70779 --- /dev/null +++ b/packages/ui/components/document/reminder-settings-picker.tsx @@ -0,0 +1,266 @@ +import { Plural, Trans } from '@lingui/react/macro'; + +import type { + TEnvelopeReminderDurationPeriod, + TEnvelopeReminderPeriod, + TEnvelopeReminderSettings, +} from '@documenso/lib/constants/envelope-reminder'; +import { Input } from '@documenso/ui/primitives/input'; +import { Label } from '@documenso/ui/primitives/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@documenso/ui/primitives/select'; + +type ReminderMode = 'enabled' | 'disabled' | 'inherit'; + +const getMode = (value: TEnvelopeReminderSettings | null | undefined): ReminderMode => { + if (value === null || value === undefined) { + return 'inherit'; + } + + if ('disabled' in value.sendAfter) { + return 'disabled'; + } + + return 'enabled'; +}; + +const getPeriodAmount = (period: TEnvelopeReminderPeriod | undefined): number => { + if (period && 'amount' in period) { + return period.amount; + } + + return 1; +}; + +const getPeriodUnit = ( + period: TEnvelopeReminderPeriod | undefined, +): TEnvelopeReminderDurationPeriod['unit'] => { + if (period && 'unit' in period) { + return period.unit; + } + + return 'day'; +}; + +export type ReminderSettingsPickerProps = { + value: TEnvelopeReminderSettings | null | undefined; + onChange: (value: TEnvelopeReminderSettings | null) => void; + disabled?: boolean; + inheritLabel?: string; +}; + +export const ReminderSettingsPicker = ({ + value, + onChange, + disabled = false, + inheritLabel, +}: ReminderSettingsPickerProps) => { + const mode = getMode(value); + + const sendAfterAmount = getPeriodAmount(value?.sendAfter); + const sendAfterUnit = getPeriodUnit(value?.sendAfter); + const repeatEveryAmount = getPeriodAmount(value?.repeatEvery); + const repeatEveryUnit = getPeriodUnit(value?.repeatEvery); + + const onModeChange = (newMode: string) => { + if (newMode === 'inherit') { + onChange(null); + return; + } + + if (newMode === 'disabled') { + onChange({ + sendAfter: { disabled: true }, + repeatEvery: { disabled: true }, + }); + return; + } + + onChange({ + sendAfter: { unit: sendAfterUnit, amount: sendAfterAmount }, + repeatEvery: { unit: repeatEveryUnit, amount: repeatEveryAmount }, + }); + }; + + const updateSendAfter = ( + updates: Partial<{ amount: number; unit: TEnvelopeReminderDurationPeriod['unit'] }>, + ) => { + const newAmount = Math.max(1, Math.floor(updates.amount ?? sendAfterAmount)); + const newUnit = updates.unit ?? sendAfterUnit; + + onChange({ + sendAfter: { unit: newUnit, amount: newAmount }, + repeatEvery: value?.repeatEvery ?? { unit: repeatEveryUnit, amount: repeatEveryAmount }, + }); + }; + + const updateRepeatEvery = ( + updates: Partial<{ amount: number; unit: TEnvelopeReminderDurationPeriod['unit'] }>, + ) => { + const newAmount = Math.max(1, Math.floor(updates.amount ?? repeatEveryAmount)); + const newUnit = updates.unit ?? repeatEveryUnit; + + onChange({ + sendAfter: value?.sendAfter ?? { unit: sendAfterUnit, amount: sendAfterAmount }, + repeatEvery: { unit: newUnit, amount: newAmount }, + }); + }; + + const onRepeatModeChange = (newMode: string) => { + if (newMode === 'disabled') { + onChange({ + sendAfter: value?.sendAfter ?? { unit: sendAfterUnit, amount: sendAfterAmount }, + repeatEvery: { disabled: true }, + }); + return; + } + + onChange({ + sendAfter: value?.sendAfter ?? { unit: sendAfterUnit, amount: sendAfterAmount }, + repeatEvery: { unit: repeatEveryUnit, amount: repeatEveryAmount }, + }); + }; + + const repeatMode = value?.repeatEvery && 'disabled' in value.repeatEvery ? 'disabled' : 'enabled'; + + return ( +
+ + + {mode === 'enabled' && ( +
+
+ + +
+ updateSendAfter({ amount: Number(e.target.value) })} + disabled={disabled} + data-testid="reminder-send-after-amount" + /> + + + updateSendAfter({ + unit: unit as TEnvelopeReminderDurationPeriod['unit'], + }) + } + disabled={disabled} + testId="reminder-send-after-unit" + /> +
+
+ +
+ + + + + {repeatMode === 'enabled' && ( +
+ updateRepeatEvery({ amount: Number(e.target.value) })} + disabled={disabled} + data-testid="reminder-repeat-amount" + /> + + + updateRepeatEvery({ + unit: unit as TEnvelopeReminderDurationPeriod['unit'], + }) + } + disabled={disabled} + testId="reminder-repeat-unit" + /> +
+ )} +
+
+ )} +
+ ); +}; + +const UnitSelect = ({ + value, + amount, + onChange, + disabled, + testId, +}: { + value: string; + amount: number; + onChange: (value: string) => void; + disabled: boolean; + testId: string; +}) => ( + +); diff --git a/packages/ui/primitives/document-flow/add-fields.tsx b/packages/ui/primitives/document-flow/add-fields.tsx index 2ed711ac2..a8e67917e 100644 --- a/packages/ui/primitives/document-flow/add-fields.tsx +++ b/packages/ui/primitives/document-flow/add-fields.tsx @@ -4,7 +4,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; -import type { Field, Recipient } from '@prisma/client'; +import type { Field } from '@prisma/client'; import { FieldType, Prisma, RecipientRole, SendStatus } from '@prisma/client'; import { CalendarDays, @@ -28,6 +28,7 @@ import { type TFieldMetaSchema as FieldMeta, ZFieldMetaSchema, } from '@documenso/lib/types/field-meta'; +import type { TRecipientLite } from '@documenso/lib/types/recipient'; import { nanoid } from '@documenso/lib/universal/id'; import { ADVANCED_FIELD_TYPES_WITH_OPTIONAL_SETTING } from '@documenso/lib/utils/advanced-fields-helpers'; import { validateFieldsUninserted } from '@documenso/lib/utils/fields'; @@ -83,7 +84,7 @@ export type FieldFormType = { export type AddFieldsFormProps = { documentFlow: DocumentFlowStep; hideRecipients?: boolean; - recipients: Recipient[]; + recipients: TRecipientLite[]; fields: Field[]; onSubmit: (_data: TAddFieldsFormSchema) => void; onAutoSave: (_data: TAddFieldsFormSchema) => Promise; @@ -172,7 +173,7 @@ export const AddFieldsFormPartial = ({ }); const [selectedField, setSelectedField] = useState(null); - const [selectedSigner, setSelectedSigner] = useState(null); + const [selectedSigner, setSelectedSigner] = useState(null); const [lastActiveField, setLastActiveField] = useState( null, ); @@ -536,7 +537,7 @@ export const AddFieldsFormPartial = ({ }, [recipients]); const recipientsByRole = useMemo(() => { - const recipientsByRole: Record = { + const recipientsByRole: Record = { CC: [], VIEWER: [], SIGNER: [], diff --git a/packages/ui/primitives/document-flow/add-settings.tsx b/packages/ui/primitives/document-flow/add-settings.tsx index 8ecfb786b..a2b748ad1 100644 --- a/packages/ui/primitives/document-flow/add-settings.tsx +++ b/packages/ui/primitives/document-flow/add-settings.tsx @@ -6,7 +6,6 @@ import { DocumentStatus, DocumentVisibility, type Field, - type Recipient, SendStatus, TeamMemberRole, } from '@prisma/client'; @@ -21,6 +20,7 @@ import { DOCUMENT_SIGNATURE_TYPES } from '@documenso/lib/constants/document'; import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n'; import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones'; import type { TDocument } from '@documenso/lib/types/document'; +import type { TRecipientLite } from '@documenso/lib/types/recipient'; import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth'; import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams'; import { @@ -74,7 +74,7 @@ import type { DocumentFlowStep } from './types'; export type AddSettingsFormProps = { documentFlow: DocumentFlowStep; - recipients: Recipient[]; + recipients: TRecipientLite[]; fields: Field[]; isDocumentPdfLoaded: boolean; document: TDocument; diff --git a/packages/ui/primitives/document-flow/add-signers.tsx b/packages/ui/primitives/document-flow/add-signers.tsx index e302401da..c66fb668a 100644 --- a/packages/ui/primitives/document-flow/add-signers.tsx +++ b/packages/ui/primitives/document-flow/add-signers.tsx @@ -6,7 +6,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; -import type { Field, Recipient } from '@prisma/client'; +import type { Field } from '@prisma/client'; import { DocumentSigningOrder, RecipientRole, SendStatus } from '@prisma/client'; import { motion } from 'framer-motion'; import { GripVerticalIcon, HelpCircle, Plus, Trash } from 'lucide-react'; @@ -19,6 +19,7 @@ import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounce import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { useSession } from '@documenso/lib/client-only/providers/session'; import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth'; +import type { TRecipientLite } from '@documenso/lib/types/recipient'; import { nanoid } from '@documenso/lib/universal/id'; import { canRecipientBeModified as utilCanRecipientBeModified } from '@documenso/lib/utils/recipients'; import { trpc } from '@documenso/trpc/react'; @@ -54,12 +55,12 @@ import { SigningOrderConfirmation } from './signing-order-confirmation'; import type { DocumentFlowStep } from './types'; type AutoSaveResponse = { - recipients: Recipient[]; + recipients: TRecipientLite[]; }; export type AddSignersFormProps = { documentFlow: DocumentFlowStep; - recipients: Recipient[]; + recipients: TRecipientLite[]; fields: Field[]; signingOrder?: DocumentSigningOrder | null; allowDictateNextSigner?: boolean; diff --git a/packages/ui/primitives/document-flow/add-subject.tsx b/packages/ui/primitives/document-flow/add-subject.tsx index 4afccd5f1..59b09d1f3 100644 --- a/packages/ui/primitives/document-flow/add-subject.tsx +++ b/packages/ui/primitives/document-flow/add-subject.tsx @@ -4,7 +4,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; -import type { Field, Recipient } from '@prisma/client'; +import type { Field } from '@prisma/client'; import { DocumentDistributionMethod, DocumentStatus, RecipientRole } from '@prisma/client'; import { AnimatePresence, motion } from 'framer-motion'; import { InfoIcon } from 'lucide-react'; @@ -15,6 +15,7 @@ import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/org import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import type { TDocument } from '@documenso/lib/types/document'; import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email'; +import type { TRecipientLite } from '@documenso/lib/types/recipient'; import { formatSigningLink } from '@documenso/lib/utils/recipients'; import { trpc } from '@documenso/trpc/react'; import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper'; @@ -59,7 +60,7 @@ import type { DocumentFlowStep } from './types'; export type AddSubjectFormProps = { documentFlow: DocumentFlowStep; - recipients: Recipient[]; + recipients: TRecipientLite[]; fields: Field[]; document: TDocument; onSubmit: (_data: TAddSubjectFormSchema) => void; diff --git a/packages/ui/primitives/recipient-selector.tsx b/packages/ui/primitives/recipient-selector.tsx index 2451a0f8d..1832e3764 100644 --- a/packages/ui/primitives/recipient-selector.tsx +++ b/packages/ui/primitives/recipient-selector.tsx @@ -2,12 +2,12 @@ import { useCallback, useMemo, useState } from 'react'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; -import type { Recipient } from '@prisma/client'; import { RecipientRole, SendStatus, SigningStatus } from '@prisma/client'; import { Check, ChevronsUpDown, Info } from 'lucide-react'; import { sortBy } from 'remeda'; import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; +import type { TRecipientLite } from '@documenso/lib/types/recipient'; import { getRecipientColorStyles } from '../lib/recipient-colors'; import { cn } from '../lib/utils'; @@ -18,9 +18,9 @@ import { Tooltip, TooltipContent, TooltipTrigger } from './tooltip'; export interface RecipientSelectorProps { className?: string; - selectedRecipient: Recipient | null; - onSelectedRecipientChange: (recipient: Recipient) => void; - recipients: Recipient[]; + selectedRecipient: TRecipientLite | null; + onSelectedRecipientChange: (recipient: TRecipientLite) => void; + recipients: TRecipientLite[]; align?: 'center' | 'end' | 'start'; } @@ -35,7 +35,7 @@ export const RecipientSelector = ({ const [showRecipientsSelector, setShowRecipientsSelector] = useState(false); const recipientsByRole = useMemo(() => { - const recipientsWithRole: Record = { + const recipientsWithRole: Record = { CC: [], VIEWER: [], SIGNER: [], @@ -67,12 +67,12 @@ export const RecipientSelector = ({ [(r) => r.signingOrder || Number.MAX_SAFE_INTEGER, 'asc'], [(r) => r.id, 'asc'], ), - ] as [RecipientRole, Recipient[]], + ] as [RecipientRole, TRecipientLite[]], ); }, [recipientsByRole]); const getRecipientLabel = useCallback( - (recipient: Recipient) => { + (recipient: TRecipientLite) => { if (recipient.name && recipient.email) { return `${recipient.name} (${recipient.email})`; } diff --git a/packages/ui/primitives/template-flow/add-template-fields.tsx b/packages/ui/primitives/template-flow/add-template-fields.tsx index 116387d38..a662abb88 100644 --- a/packages/ui/primitives/template-flow/add-template-fields.tsx +++ b/packages/ui/primitives/template-flow/add-template-fields.tsx @@ -4,7 +4,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; -import type { Field, Recipient } from '@prisma/client'; +import type { Field } from '@prisma/client'; import { FieldType, RecipientRole, SendStatus } from '@prisma/client'; import { CalendarDays, @@ -31,6 +31,7 @@ import { type TFieldMetaSchema as FieldMeta, ZFieldMetaSchema, } from '@documenso/lib/types/field-meta'; +import type { TRecipientLite } from '@documenso/lib/types/recipient'; import { nanoid } from '@documenso/lib/universal/id'; import { ADVANCED_FIELD_TYPES_WITH_OPTIONAL_SETTING } from '@documenso/lib/utils/advanced-fields-helpers'; import { parseMessageDescriptor } from '@documenso/lib/utils/i18n'; @@ -75,7 +76,7 @@ const DEFAULT_WIDTH_PX = MIN_WIDTH_PX * 2.5; export type AddTemplateFieldsFormProps = { documentFlow: DocumentFlowStep; - recipients: Recipient[]; + recipients: TRecipientLite[]; fields: Field[]; onSubmit: (_data: TAddTemplateFieldsFormSchema) => void; onAutoSave: (_data: TAddTemplateFieldsFormSchema) => Promise; @@ -154,7 +155,7 @@ export const AddTemplateFieldsFormPartial = ({ }); const [selectedField, setSelectedField] = useState(null); - const [selectedSigner, setSelectedSigner] = useState(null); + const [selectedSigner, setSelectedSigner] = useState(null); const [showRecipientsSelector, setShowRecipientsSelector] = useState(false); const selectedSignerIndex = recipients.findIndex((r) => r.id === selectedSigner?.id); @@ -491,7 +492,7 @@ export const AddTemplateFieldsFormPartial = ({ }, [recipients]); const recipientsByRole = useMemo(() => { - const recipientsByRole: Record = { + const recipientsByRole: Record = { CC: [], VIEWER: [], SIGNER: [], @@ -520,7 +521,7 @@ export const AddTemplateFieldsFormPartial = ({ const recipientsByRoleToDisplay = useMemo(() => { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - return (Object.entries(recipientsByRole) as [RecipientRole, Recipient[]][]).filter( + return (Object.entries(recipientsByRole) as [RecipientRole, TRecipientLite[]][]).filter( ([role]) => role !== RecipientRole.CC && role !== RecipientRole.VIEWER && diff --git a/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx b/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx index ae562a07f..011011f57 100644 --- a/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx +++ b/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx @@ -7,7 +7,7 @@ import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; import type { TemplateDirectLink } from '@prisma/client'; -import { DocumentSigningOrder, type Field, type Recipient, RecipientRole } from '@prisma/client'; +import { DocumentSigningOrder, type Field, RecipientRole } from '@prisma/client'; import { motion } from 'framer-motion'; import { GripVerticalIcon, HelpCircle, Link2Icon, Plus, Trash } from 'lucide-react'; import { useFieldArray, useForm } from 'react-hook-form'; @@ -17,6 +17,7 @@ import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/org import { useSession } from '@documenso/lib/client-only/providers/session'; import { isTemplateRecipientEmailPlaceholder } from '@documenso/lib/constants/template'; import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth'; +import type { TRecipientLite } from '@documenso/lib/types/recipient'; import { nanoid } from '@documenso/lib/universal/id'; import { generateRecipientPlaceholder } from '@documenso/lib/utils/templates'; import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out'; @@ -49,12 +50,12 @@ import type { TAddTemplatePlacholderRecipientsFormSchema } from './add-template- import { ZAddTemplatePlacholderRecipientsFormSchema } from './add-template-placeholder-recipients.types'; type AutoSaveResponse = { - recipients: Recipient[]; + recipients: TRecipientLite[]; }; export type AddTemplatePlaceholderRecipientsFormProps = { documentFlow: DocumentFlowStep; - recipients: Recipient[]; + recipients: TRecipientLite[]; fields: Field[]; signingOrder?: DocumentSigningOrder | null; allowDictateNextSigner?: boolean; diff --git a/packages/ui/primitives/template-flow/add-template-settings.tsx b/packages/ui/primitives/template-flow/add-template-settings.tsx index cbb9137ac..b1ae42bdf 100644 --- a/packages/ui/primitives/template-flow/add-template-settings.tsx +++ b/packages/ui/primitives/template-flow/add-template-settings.tsx @@ -3,7 +3,7 @@ import { useEffect } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; import { Trans, useLingui } from '@lingui/react/macro'; import { DocumentVisibility, TeamMemberRole, TemplateType } from '@prisma/client'; -import { DocumentDistributionMethod, type Field, type Recipient } from '@prisma/client'; +import { DocumentDistributionMethod, type Field } from '@prisma/client'; import { InfoIcon } from 'lucide-react'; import { useForm } from 'react-hook-form'; import { match } from 'ts-pattern'; @@ -19,6 +19,7 @@ import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n'; import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones'; import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email'; import type { TDocumentMetaDateFormat } from '@documenso/lib/types/document-meta'; +import type { TRecipientLite } from '@documenso/lib/types/recipient'; import type { TTemplate } from '@documenso/lib/types/template'; import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth'; import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams'; @@ -81,7 +82,7 @@ import { ZAddTemplateSettingsFormSchema } from './add-template-settings.types'; export type AddTemplateSettingsFormProps = { documentFlow: DocumentFlowStep; - recipients: Recipient[]; + recipients: TRecipientLite[]; fields: Field[]; isDocumentPdfLoaded: boolean; template: TTemplate; From bc82b2e70e23b7ddc41fd3caa1d2847b1c6c4407 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Tue, 14 Apr 2026 21:17:16 +1000 Subject: [PATCH 2/7] fix: admin org sorting (#2694) --- .../trpc/server/admin-router/find-admin-organisations.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/trpc/server/admin-router/find-admin-organisations.ts b/packages/trpc/server/admin-router/find-admin-organisations.ts index e1a47566c..9e82bbec1 100644 --- a/packages/trpc/server/admin-router/find-admin-organisations.ts +++ b/packages/trpc/server/admin-router/find-admin-organisations.ts @@ -120,14 +120,16 @@ export const findAdminOrganisations = async ({ }; } + const orderBy: Prisma.OrganisationOrderByWithRelationInput[] = query + ? [{ subscription: { status: 'asc' } }, { name: 'asc' }] + : [{ createdAt: 'desc' }]; + const [data, count] = await Promise.all([ prisma.organisation.findMany({ where: whereClause, skip: Math.max(page - 1, 0) * perPage, take: perPage, - orderBy: { - createdAt: 'desc', - }, + orderBy, select: { id: true, createdAt: true, From 5082226e0891d0701c365ae367eaa2b98c959b1c Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Tue, 14 Apr 2026 21:18:17 +1000 Subject: [PATCH 3/7] fix: brand logo caching (#2699) --- .../forms/branding-preferences-form.tsx | 14 ++++++------- .../o.$orgUrl.settings.branding.tsx | 7 ++++++- .../t.$teamUrl+/settings.branding.tsx | 5 +++-- .../api+/branding.logo.organisation.$orgId.ts | 21 ++++++++++++++++--- .../routes/api+/branding.logo.team.$teamId.ts | 21 ++++++++++++++++--- 5 files changed, 52 insertions(+), 16 deletions(-) diff --git a/apps/remix/app/components/forms/branding-preferences-form.tsx b/apps/remix/app/components/forms/branding-preferences-form.tsx index 9c8bc05ce..b835c653a 100644 --- a/apps/remix/app/components/forms/branding-preferences-form.tsx +++ b/apps/remix/app/components/forms/branding-preferences-form.tsx @@ -98,7 +98,7 @@ export function BrandingPreferencesForm({ ? `${NEXT_PUBLIC_WEBAPP_URL()}/api/branding/logo/team/${team?.id}` : `${NEXT_PUBLIC_WEBAPP_URL()}/api/branding/logo/organisation/${organisation?.id}`; - setPreviewUrl(logoUrl); + setPreviewUrl(logoUrl + '?v=' + Date.now()); setHasLoadedPreview(true); } } @@ -173,7 +173,7 @@ export function BrandingPreferencesForm({ />
- {!isBrandingEnabled &&
} + {!isBrandingEnabled &&
}
-
+
{previewUrl ? ( ) : ( -
+
Please upload a logo {!hasLoadedPreview && ( -
- +
+
)}
@@ -243,7 +243,7 @@ export function BrandingPreferencesForm({ type="button" variant="link" size="sm" - className="text-destructive text-xs" + className="text-xs text-destructive" onClick={() => { setPreviewUrl(''); onChange(null); diff --git a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx index 64f8511ed..8f5e47117 100644 --- a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx +++ b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx @@ -48,12 +48,17 @@ export default function OrganisationSettingsBrandingPage() { try { const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails } = data; - let uploadedBrandingLogo: string | undefined = ''; + let uploadedBrandingLogo: string | undefined = undefined; if (brandingLogo) { uploadedBrandingLogo = JSON.stringify(await putFile(brandingLogo)); } + // Empty the branding logo if the user unsets it. + if (brandingLogo === null) { + uploadedBrandingLogo = ''; + } + await updateOrganisationSettings({ organisationId: organisation.id, data: { diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx index a7317505f..9ffc05255 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx @@ -34,12 +34,13 @@ export default function TeamsSettingsPage() { try { const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails } = data; - let uploadedBrandingLogo = teamWithSettings?.teamSettings?.brandingLogo; + let uploadedBrandingLogo: string | undefined = undefined; if (brandingLogo) { uploadedBrandingLogo = JSON.stringify(await putFile(brandingLogo)); } + // Empty the branding logo if the user unsets it. if (brandingLogo === null) { uploadedBrandingLogo = ''; } @@ -48,7 +49,7 @@ export default function TeamsSettingsPage() { teamId: team.id, data: { brandingEnabled, - brandingLogo: uploadedBrandingLogo || null, + brandingLogo: uploadedBrandingLogo, brandingUrl: brandingUrl || null, brandingCompanyDetails: brandingCompanyDetails || null, }, diff --git a/apps/remix/app/routes/api+/branding.logo.organisation.$orgId.ts b/apps/remix/app/routes/api+/branding.logo.organisation.$orgId.ts index da6c8da1a..050bb4a6b 100644 --- a/apps/remix/app/routes/api+/branding.logo.organisation.$orgId.ts +++ b/apps/remix/app/routes/api+/branding.logo.organisation.$orgId.ts @@ -1,10 +1,13 @@ +import { sha256 } from '@documenso/lib/universal/crypto'; import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server'; import { loadLogo } from '@documenso/lib/utils/images/logo'; import { prisma } from '@documenso/prisma'; import type { Route } from './+types/branding.logo.organisation.$orgId'; -export async function loader({ params }: Route.LoaderArgs) { +const CACHE_CONTROL = 'public, max-age=0, stale-while-revalidate=86400'; + +export async function loader({ params, request }: Route.LoaderArgs) { const organisationId = params.orgId; if (!organisationId) { @@ -48,6 +51,18 @@ export async function loader({ params }: Route.LoaderArgs) { ); } + const etag = `"${Buffer.from(sha256(settings.brandingLogo)).toString('hex')}"`; + + if (request.headers.get('If-None-Match') === etag) { + return new Response(null, { + status: 304, + headers: { + ETag: etag, + 'Cache-Control': CACHE_CONTROL, + }, + }); + } + const file = await getFileServerSide(JSON.parse(settings.brandingLogo)).catch((e) => { console.error(e); }); @@ -68,8 +83,8 @@ export async function loader({ params }: Route.LoaderArgs) { headers: { 'Content-Type': contentType, 'Content-Length': content.length.toString(), - // Stale while revalidate for 1 hours to 24 hours - 'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400', + 'Cache-Control': CACHE_CONTROL, + ETag: etag, }, }); } diff --git a/apps/remix/app/routes/api+/branding.logo.team.$teamId.ts b/apps/remix/app/routes/api+/branding.logo.team.$teamId.ts index 30a0e5b3c..923e8e0ca 100644 --- a/apps/remix/app/routes/api+/branding.logo.team.$teamId.ts +++ b/apps/remix/app/routes/api+/branding.logo.team.$teamId.ts @@ -1,10 +1,13 @@ import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settings'; +import { sha256 } from '@documenso/lib/universal/crypto'; import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server'; import { loadLogo } from '@documenso/lib/utils/images/logo'; import type { Route } from './+types/branding.logo.team.$teamId'; -export async function loader({ params }: Route.LoaderArgs) { +const CACHE_CONTROL = 'public, max-age=0, stale-while-revalidate=86400'; + +export async function loader({ params, request }: Route.LoaderArgs) { const teamId = Number(params.teamId); if (teamId === 0 || Number.isNaN(teamId)) { @@ -41,6 +44,18 @@ export async function loader({ params }: Route.LoaderArgs) { ); } + const etag = `"${Buffer.from(sha256(settings.brandingLogo)).toString('hex')}"`; + + if (request.headers.get('If-None-Match') === etag) { + return new Response(null, { + status: 304, + headers: { + ETag: etag, + 'Cache-Control': CACHE_CONTROL, + }, + }); + } + const file = await getFileServerSide(JSON.parse(settings.brandingLogo)).catch((e) => { console.error(e); }); @@ -61,8 +76,8 @@ export async function loader({ params }: Route.LoaderArgs) { headers: { 'Content-Type': contentType, 'Content-Length': content.length.toString(), - // Stale while revalidate for 1 hours to 24 hours - 'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400', + 'Cache-Control': CACHE_CONTROL, + ETag: etag, }, }); } From f54a8ed72f022d4ed59ba06050edd78710e0674a Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Thu, 16 Apr 2026 14:29:07 +1000 Subject: [PATCH 4/7] feat: add turnstile captcha to auth flow (#2703) --- .env.example | 8 ++ apps/remix/app/components/forms/signin.tsx | 31 ++++- apps/remix/app/components/forms/signup.tsx | 35 ++++-- package-lock.json | 11 ++ package.json | 1 + packages/auth/server/routes/email-password.ts | 15 ++- packages/auth/server/types/email-password.ts | 2 + packages/lib/errors/app-error.ts | 2 + .../lib/server-only/captcha/verify-captcha.ts | 107 ++++++++++++++++++ .../lib/server-only/rate-limit/rate-limits.ts | 4 +- packages/tsconfig/process-env.d.ts | 6 + turbo.json | 4 +- 12 files changed, 211 insertions(+), 15 deletions(-) create mode 100644 packages/lib/server-only/captcha/verify-captcha.ts diff --git a/.env.example b/.env.example index d9d430824..f92132f2b 100644 --- a/.env.example +++ b/.env.example @@ -182,6 +182,14 @@ GOOGLE_VERTEX_LOCATION="global" # https://console.cloud.google.com/vertex-ai/studio/settings/api-keys GOOGLE_VERTEX_API_KEY="" +# [[CLOUDFLARE TURNSTILE]] +# OPTIONAL: Cloudflare Turnstile site key (public). When configured, Turnstile challenges +# will be shown on sign-up (visible) and sign-in (invisible) pages. +# See: https://developers.cloudflare.com/turnstile/ +NEXT_PUBLIC_TURNSTILE_SITE_KEY= +# OPTIONAL: Cloudflare Turnstile secret key (server-side verification). +NEXT_PRIVATE_TURNSTILE_SECRET_KEY= + # [[E2E Tests]] E2E_TEST_AUTHENTICATE_USERNAME="Test User" E2E_TEST_AUTHENTICATE_USER_EMAIL="testuser@mail.com" diff --git a/apps/remix/app/components/forms/signin.tsx b/apps/remix/app/components/forms/signin.tsx index 27a900276..6a1763d46 100644 --- a/apps/remix/app/components/forms/signin.tsx +++ b/apps/remix/app/components/forms/signin.tsx @@ -1,10 +1,12 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; import type { MessageDescriptor } from '@lingui/core'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; +import type { TurnstileInstance } from '@marsidev/react-turnstile'; +import { Turnstile } from '@marsidev/react-turnstile'; import { browserSupportsWebAuthn, startAuthentication } from '@simplewebauthn/browser'; import { KeyRoundIcon } from 'lucide-react'; import { useForm } from 'react-hook-form'; @@ -16,7 +18,8 @@ import { z } from 'zod'; import { authClient } from '@documenso/auth/client'; import { AuthenticationErrorCode } from '@documenso/auth/server/lib/errors/error-codes'; -import { AppError } from '@documenso/lib/errors/app-error'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { env } from '@documenso/lib/utils/env'; import { zEmail } from '@documenso/lib/utils/zod'; import { trpc } from '@documenso/trpc/react'; import { ZCurrentPasswordSchema } from '@documenso/trpc/server/auth-router/schema'; @@ -101,6 +104,10 @@ export const SignInForm = ({ const hasSocialAuthEnabled = isGoogleSSOEnabled || isMicrosoftSSOEnabled || isOIDCSSOEnabled; + const turnstileSiteKey = env('NEXT_PUBLIC_TURNSTILE_SITE_KEY'); + const turnstileRef = useRef(null); + const [captchaToken, setCaptchaToken] = useState(null); + const [isPasskeyLoading, setIsPasskeyLoading] = useState(false); const redirectPath = useMemo(() => { @@ -217,6 +224,7 @@ export const SignInForm = ({ password, totpCode, backupCode, + captchaToken: captchaToken ?? undefined, redirectPath, }); } catch (err) { @@ -251,6 +259,10 @@ export const SignInForm = ({ AuthenticationErrorCode.InvalidTwoFactorCode, () => msg`The two-factor authentication code provided is incorrect.`, ) + .with( + AppErrorCode.INVALID_CAPTCHA, + () => msg`We were unable to verify that you're human. Please try again.`, + ) .otherwise(() => handleFallbackErrorMessages(error.code)); toast({ @@ -258,6 +270,9 @@ export const SignInForm = ({ description: _(errorMessage), variant: 'destructive', }); + + turnstileRef.current?.reset(); + setCaptchaToken(null); } }; @@ -378,6 +393,18 @@ export const SignInForm = ({ )} /> + {turnstileSiteKey && ( + setCaptchaToken(null)} + options={{ + size: 'invisible', + }} + /> + )} +