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/.github/actions/node-install/action.yml b/.github/actions/node-install/action.yml index f86dd5e42..bea5a0a49 100644 --- a/.github/actions/node-install/action.yml +++ b/.github/actions/node-install/action.yml @@ -12,6 +12,10 @@ runs: with: node-version: ${{ inputs.node_version }} + - name: Enable corepack + shell: bash + run: corepack enable npm + - name: Cache npm uses: actions/cache@v3 with: diff --git a/.gitignore b/.gitignore index 94cafb608..6e1c5420b 100644 --- a/.gitignore +++ b/.gitignore @@ -71,3 +71,6 @@ scripts/bench-* # tmp tmp/ + +# opencode +.opencode/package-lock.json diff --git a/.npmrc b/.npmrc index 7a1b32fd4..cbc6b6537 100644 --- a/.npmrc +++ b/.npmrc @@ -1,2 +1,3 @@ legacy-peer-deps = true -prefer-dedupe = true \ No newline at end of file +prefer-dedupe = true +min-release-age = 7 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/docs/package.json b/apps/docs/package.json index e7dfada04..8e6f820db 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -16,7 +16,7 @@ "fumadocs-ui": "16.5.0", "lucide-react": "^0.563.0", "mermaid": "^11.12.2", - "next": "16.1.6", + "next": "16.2.4", "next-plausible": "^3.12.5", "next-themes": "^0.4.6", "react": "^19.2.4", diff --git a/apps/openpage-api/package.json b/apps/openpage-api/package.json index 8657dca8d..1d6d168c3 100644 --- a/apps/openpage-api/package.json +++ b/apps/openpage-api/package.json @@ -12,7 +12,7 @@ "dependencies": { "@documenso/prisma": "*", "luxon": "^3.7.2", - "next": "15.5.12" + "next": "16.2.4" }, "devDependencies": { "@types/node": "^20", diff --git a/apps/openpage-api/tsconfig.json b/apps/openpage-api/tsconfig.json index d8b93235f..705f5ce5e 100644 --- a/apps/openpage-api/tsconfig.json +++ b/apps/openpage-api/tsconfig.json @@ -11,7 +11,7 @@ "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, - "jsx": "preserve", + "jsx": "react-jsx", "incremental": true, "plugins": [ { @@ -22,6 +22,12 @@ "@/*": ["./*"] } }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" + ], "exclude": ["node_modules"] } 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/email/transports/mailchannels.ts b/packages/email/transports/mailchannels.ts index 45b12f739..1c74694c0 100644 --- a/packages/email/transports/mailchannels.ts +++ b/packages/email/transports/mailchannels.ts @@ -54,13 +54,11 @@ export class MailChannelsTransport implements Transport { const mailCc = this.toMailChannelsAddresses(mail.data.cc); const mailBcc = this.toMailChannelsAddresses(mail.data.bcc); - const from: MailChannelsAddress = - typeof mail.data.from === 'string' - ? { email: mail.data.from } - : { - email: mail.data.from?.address, - name: mail.data.from?.name, - }; + const [from] = this.toMailChannelsAddresses(mail.data.from); + + if (!from) { + return callback(new Error('Missing required field "from"'), null); + } const requestHeaders: Record = { 'Content-Type': 'application/json', diff --git a/packages/eslint-config/index.cjs b/packages/eslint-config/index.cjs index e1fd2f3cc..21552e6fc 100644 --- a/packages/eslint-config/index.cjs +++ b/packages/eslint-config/index.cjs @@ -1,5 +1,5 @@ module.exports = { - extends: ['next', 'turbo', 'eslint:recommended', 'plugin:@typescript-eslint/recommended'], + extends: ['turbo', 'eslint:recommended', 'plugin:@typescript-eslint/recommended'], plugins: ['unused-imports'], @@ -22,8 +22,7 @@ module.exports = { }, rules: { - '@next/next/no-html-link-for-pages': 'off', - 'react/no-unescaped-entities': 'off', + // 'react/no-unescaped-entities': 'off', '@typescript-eslint/no-unused-vars': 'off', 'unused-imports/no-unused-imports': 'warn', diff --git a/packages/eslint-config/package.json b/packages/eslint-config/package.json index 4b099452d..f52dd6c98 100644 --- a/packages/eslint-config/package.json +++ b/packages/eslint-config/package.json @@ -10,11 +10,10 @@ "@typescript-eslint/eslint-plugin": "^7.18.0", "@typescript-eslint/parser": "^7.18.0", "eslint": "^8.57.0", - "eslint-config-next": "^15", "eslint-config-turbo": "^1.13.4", "eslint-plugin-package-json": "^0.85.0", "eslint-plugin-react": "^7.37.5", "eslint-plugin-unused-imports": "^4.3.0", "typescript": "5.6.2" } -} \ No newline at end of file +} 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/errors/app-error.ts b/packages/lib/errors/app-error.ts index 48156d077..4083bd623 100644 --- a/packages/lib/errors/app-error.ts +++ b/packages/lib/errors/app-error.ts @@ -13,6 +13,7 @@ export enum AppErrorCode { 'LIMIT_EXCEEDED' = 'LIMIT_EXCEEDED', 'NOT_FOUND' = 'NOT_FOUND', 'NOT_SETUP' = 'NOT_SETUP', + 'INVALID_CAPTCHA' = 'INVALID_CAPTCHA', 'UNAUTHORIZED' = 'UNAUTHORIZED', 'UNKNOWN_ERROR' = 'UNKNOWN_ERROR', 'RETRY_EXCEPTION' = 'RETRY_EXCEPTION', @@ -29,6 +30,7 @@ export const genericErrorCodeToTrpcErrorCodeMap: Record { 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/package.json b/packages/lib/package.json index 7e56275d6..4855f2e60 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -46,11 +46,11 @@ "ai": "^5.0.104", "bullmq": "^5.71.1", "csv-parse": "^6.1.0", - "inngest": "^3.45.1", + "inngest": "^3.54.0", "ioredis": "^5.10.1", "jose": "^6.1.2", "konva": "^10.0.9", - "kysely": "0.28.8", + "kysely": "0.28.16", "luxon": "^3.7.2", "nanoid": "^5.1.6", "oslo": "^0.17.0", diff --git a/packages/lib/server-only/captcha/verify-captcha.ts b/packages/lib/server-only/captcha/verify-captcha.ts new file mode 100644 index 000000000..546c994bd --- /dev/null +++ b/packages/lib/server-only/captcha/verify-captcha.ts @@ -0,0 +1,107 @@ +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; + +import { logger } from '../../utils/logger'; + +const TURNSTILE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'; + +type TurnstileVerifyResponse = { + success: boolean; + 'error-codes': string[]; + challenge_ts?: string; + hostname?: string; +}; + +/** + * Verify a captcha token server-side. + * + * Currently supports Cloudflare Turnstile. This is a no-op if + * `NEXT_PRIVATE_TURNSTILE_SECRET_KEY` is not configured, making captcha + * verification an opt-in feature. + */ +export const verifyCaptchaToken = async ({ + token, + ipAddress, +}: { + token?: string | null; + ipAddress?: string | null; +}) => { + const secretKey = process.env.NEXT_PRIVATE_TURNSTILE_SECRET_KEY; + + // If no secret key is configured, skip verification. + if (!secretKey) { + return; + } + + if (!token) { + logger.warn({ + msg: 'Captcha verification rejected: missing token', + ipAddress, + }); + + throw new AppError(AppErrorCode.INVALID_CAPTCHA, { + message: 'Captcha token is required', + statusCode: 400, + }); + } + + const formData = new URLSearchParams(); + + formData.append('secret', secretKey); + formData.append('response', token); + + if (ipAddress) { + formData.append('remoteip', ipAddress); + } + + let response: Response; + + try { + response = await fetch(TURNSTILE_VERIFY_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: formData.toString(), + }); + } catch (err) { + logger.error({ + msg: 'Captcha verification failed: network error calling siteverify', + err, + ipAddress, + }); + + throw new AppError(AppErrorCode.INVALID_CAPTCHA, { + message: 'Captcha verification failed', + statusCode: 400, + }); + } + + if (!response.ok) { + logger.error({ + msg: 'Captcha verification failed: non-2xx response from siteverify', + status: response.status, + ipAddress, + }); + + throw new AppError(AppErrorCode.INVALID_CAPTCHA, { + message: `Captcha verification request failed with status ${response.status}`, + statusCode: 400, + }); + } + + const result: TurnstileVerifyResponse = await response.json(); + + if (!result.success) { + logger.warn({ + msg: 'Captcha verification rejected by provider', + errorCodes: result['error-codes'], + hostname: result.hostname, + ipAddress, + }); + + throw new AppError(AppErrorCode.INVALID_CAPTCHA, { + message: `Captcha verification failed: ${result['error-codes']?.join(', ') ?? 'unknown'}`, + statusCode: 400, + }); + } +}; 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/rate-limit/rate-limits.ts b/packages/lib/server-only/rate-limit/rate-limits.ts index 25069a3bf..a067546a4 100644 --- a/packages/lib/server-only/rate-limit/rate-limits.ts +++ b/packages/lib/server-only/rate-limit/rate-limits.ts @@ -4,8 +4,8 @@ import { createRateLimit } from './rate-limit'; export const signupRateLimit = createRateLimit({ action: 'auth.signup', - max: 10, - window: '1h', + max: 3, + window: '3h', }); export const forgotPasswordRateLimit = createRateLimit({ 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/tsconfig/process-env.d.ts b/packages/tsconfig/process-env.d.ts index f783dd075..16be71dc1 100644 --- a/packages/tsconfig/process-env.d.ts +++ b/packages/tsconfig/process-env.d.ts @@ -102,6 +102,12 @@ declare namespace NodeJS { POSTGRES_PRISMA_URL?: string; POSTGRES_URL_NON_POOLING?: string; + /** + * Cloudflare Turnstile environment variables + */ + NEXT_PUBLIC_TURNSTILE_SITE_KEY?: string; + NEXT_PRIVATE_TURNSTILE_SECRET_KEY?: string; + /** * Google Vertex AI environment variables */ 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; diff --git a/packages/ui/primitives/tooltip.tsx b/packages/ui/primitives/tooltip.tsx index f68599cd4..63370da96 100644 --- a/packages/ui/primitives/tooltip.tsx +++ b/packages/ui/primitives/tooltip.tsx @@ -8,7 +8,14 @@ const TooltipProvider = TooltipPrimitive.Provider; const Tooltip = TooltipPrimitive.Root; -const TooltipTrigger = TooltipPrimitive.Trigger; +const TooltipTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ type = 'button', ...props }, ref) => ( + +)); + +TooltipTrigger.displayName = TooltipPrimitive.Trigger.displayName; const TooltipContent = React.forwardRef< React.ElementRef, diff --git a/turbo.json b/turbo.json index 216fa8915..2a0bbff9c 100644 --- a/turbo.json +++ b/turbo.json @@ -141,6 +141,8 @@ "DANGEROUS_BYPASS_RATE_LIMITS", "NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS", "NEXT_PRIVATE_OIDC_PROMPT", - "NEXT_PRIVATE_WEBHOOK_SSRF_BYPASS_HOSTS" + "NEXT_PRIVATE_WEBHOOK_SSRF_BYPASS_HOSTS", + "NEXT_PUBLIC_TURNSTILE_SITE_KEY", + "NEXT_PRIVATE_TURNSTILE_SECRET_KEY" ] }