diff --git a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx index 15f064fea..447f1948b 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx @@ -203,7 +203,7 @@ export const EditDocumentForm = ({ const onAddSettingsFormSubmit = async (data: TAddSettingsFormSchema) => { try { - const { timezone, dateFormat, redirectUrl, language, reminderDays } = data.meta; + const { timezone, dateFormat, redirectUrl, language, reminderInterval } = data.meta; await setSettingsForDocument({ documentId: document.id, @@ -220,7 +220,7 @@ export const EditDocumentForm = ({ dateFormat, redirectUrl, language: isValidLanguageCode(language) ? language : undefined, - reminderDays, + reminderInterval, }, }); diff --git a/packages/lib/jobs/client.ts b/packages/lib/jobs/client.ts index 91e1bba83..068f74806 100644 --- a/packages/lib/jobs/client.ts +++ b/packages/lib/jobs/client.ts @@ -1,8 +1,8 @@ import { JobClient } from './client/client'; -import { TEST_CRON_JOB_DEFINITION } from './definitions/cron/test-cron'; import { SEND_CONFIRMATION_EMAIL_JOB_DEFINITION } from './definitions/emails/send-confirmation-email'; import { SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION } from './definitions/emails/send-rejection-emails'; import { SEND_SIGNING_EMAIL_JOB_DEFINITION } from './definitions/emails/send-signing-email'; +import { SEND_SIGNING_REMINDER_EMAIL_JOB } from './definitions/emails/send-signing-reminder-email'; import { SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-deleted-email'; import { SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-member-joined-email'; import { SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-member-left-email'; @@ -20,7 +20,7 @@ export const jobsClient = new JobClient([ SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION, SEAL_DOCUMENT_JOB_DEFINITION, SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION, - TEST_CRON_JOB_DEFINITION, + SEND_SIGNING_REMINDER_EMAIL_JOB, ] as const); export const jobs = jobsClient; diff --git a/packages/lib/jobs/client/_internal/job.ts b/packages/lib/jobs/client/_internal/job.ts index 80bee2c99..d0fbf08e7 100644 --- a/packages/lib/jobs/client/_internal/job.ts +++ b/packages/lib/jobs/client/_internal/job.ts @@ -26,10 +26,10 @@ export type TriggerJobOptions = }; }[number]; -export type CronTrigger = { +export type CronTrigger = { type: 'cron'; schedule: string; - name?: string; + name: N; }; export type EventTrigger = { @@ -45,7 +45,7 @@ export type JobDefinition = { enabled?: boolean; trigger: | (EventTrigger & { schema?: z.ZodType }) - | (CronTrigger & { schema?: z.ZodType }); + | (CronTrigger & { schema?: z.ZodType }); handler: (options: { payload: Schema; io: JobRunIO }) => Promise; }; diff --git a/packages/lib/jobs/definitions/cron/test-cron.ts b/packages/lib/jobs/definitions/cron/test-cron.ts deleted file mode 100644 index 1f4c81dca..000000000 --- a/packages/lib/jobs/definitions/cron/test-cron.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { prisma } from '@documenso/prisma'; - -import type { JobDefinition } from '../../client/_internal/job'; - -const TEST_CRON_JOB_DEFINITION_ID = 'test.cron'; - -export const TEST_CRON_JOB_DEFINITION = { - id: TEST_CRON_JOB_DEFINITION_ID, - name: 'Test Cron Job', - version: '1.0.0', - trigger: { - type: 'cron', - schedule: '* * * * *', - }, - handler: async ({ io }) => { - // send a mail to all recipients of all documents - const documents = await prisma.document.findMany({}); - - console.log(`Found ${documents.length} unsigned documents`); - - for (const document of documents) { - // eslint-disable-next-line @typescript-eslint/require-await - await io.runTask(`send-reminder-${document.id}-${document.id}`, async () => { - console.log(`Sent reminder for document ${document.id} to recipient ${document.id}`); - }); - } - }, -} as const satisfies JobDefinition; diff --git a/packages/lib/jobs/definitions/emails/send-signing-reminder-email.ts b/packages/lib/jobs/definitions/emails/send-signing-reminder-email.ts new file mode 100644 index 000000000..f787cdf6f --- /dev/null +++ b/packages/lib/jobs/definitions/emails/send-signing-reminder-email.ts @@ -0,0 +1,169 @@ +import { createElement } from 'react'; + +import { msg } from '@lingui/macro'; + +import { mailer } from '@documenso/email/mailer'; +import DocumentInviteEmailTemplate from '@documenso/email/templates/document-invite'; +import { prisma } from '@documenso/prisma'; +import { DocumentStatus, RecipientRole, SendStatus } from '@documenso/prisma/client'; +import { ReminderInterval, SigningStatus } from '@documenso/prisma/generated/types'; + +import { getI18nInstance } from '../../../client-only/providers/i18n.server'; +import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app'; +import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email'; +import { RECIPIENT_ROLES_DESCRIPTION } from '../../../constants/recipient-roles'; +import { extractDerivedDocumentEmailSettings } from '../../../types/document-email'; +import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n'; +import { shouldSendReminder } from '../../../utils/should-send-reminder'; +import type { JobDefinition, JobRunIO } from '../../client/_internal/job'; + +export type SendSigningReminderEmailHandlerOptions = { + io: JobRunIO; +}; + +const SEND_SIGNING_REMINDER_EMAIL_JOB_ID = 'send.signing.reminder.email'; + +export const SEND_SIGNING_REMINDER_EMAIL_JOB = { + id: SEND_SIGNING_REMINDER_EMAIL_JOB_ID, + name: 'Send Signing Reminder Email', + version: '1.0.0', + trigger: { + type: 'cron', + schedule: '*/5 * * * *', + name: SEND_SIGNING_REMINDER_EMAIL_JOB_ID, + }, + handler: async ({ io }) => { + const now = new Date(); + + const documentWithReminders = await prisma.document.findMany({ + where: { + status: DocumentStatus.PENDING, + documentMeta: { + reminderInterval: { + not: ReminderInterval.NONE, + }, + }, + deletedAt: null, + }, + + include: { + documentMeta: true, + User: true, + Recipient: { + where: { + signingStatus: SigningStatus.NOT_SIGNED, + role: { + not: RecipientRole.CC, + }, + }, + }, + }, + }); + + console.log(documentWithReminders); + + for (const document of documentWithReminders) { + if (!extractDerivedDocumentEmailSettings(document.documentMeta).recipientSigningRequest) { + continue; + } + + const { documentMeta } = document; + if (!documentMeta) { + return; + } + + const { reminderInterval, lastReminderSentAt } = documentMeta; + if ( + !shouldSendReminder({ + reminderInterval, + lastReminderSentAt, + now, + }) + ) { + continue; + } + + for (const recipient of document.Recipient) { + const i18n = await getI18nInstance(document.documentMeta?.language); + const recipientActionVerb = i18n + ._(RECIPIENT_ROLES_DESCRIPTION[recipient.role].actionVerb) + .toLowerCase(); + + const emailSubject = i18n._( + msg`Reminder: Please ${recipientActionVerb} the document "${document.title}"`, + ); + const emailMessage = i18n._( + msg`This is a reminder to ${recipientActionVerb} the document "${document.title}". Please complete this at your earliest convenience.`, + ); + + const signDocumentLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`; + const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000'; + + const template = createElement(DocumentInviteEmailTemplate, { + documentName: document.title, + inviterName: document.User.name || undefined, + inviterEmail: document.User.email, + assetBaseUrl, + signDocumentLink, + customBody: emailMessage, + role: recipient.role, + selfSigner: recipient.email === document.User.email, + }); + + await io.runTask('send-reminder-email', async () => { + const [html, text] = await Promise.all([ + renderEmailWithI18N(template, { lang: document.documentMeta?.language }), + renderEmailWithI18N(template, { + lang: document.documentMeta?.language, + plainText: true, + }), + ]); + + await mailer.sendMail({ + to: { + name: recipient.name, + address: recipient.email, + }, + from: { + name: FROM_NAME, + address: FROM_ADDRESS, + }, + subject: emailSubject, + html, + text, + }); + }); + + await io.runTask('update-recipient-status', async () => { + await prisma.recipient.update({ + where: { id: recipient.id }, + data: { sendStatus: SendStatus.SENT }, + }); + }); + + // TODO: Duncan == Audit log + // await io.runTask('store-reminder-audit-log', async () => { + // await prisma.documentAuditLog.create({ + // data: createDocumentAuditLogData({ + // type: DOCUMENT_AUDIT_LOG_TYPE.REMINDER_SENT, + // documentId: document.id, + // user, + // requestMetadata, + // data: { + // recipientId: recipient.id, + // recipientName: recipient.name, + // recipientEmail: recipient.email, + // recipientRole: recipient.role, + // }, + // }), + // }); + // }); + } + + await prisma.documentMeta.update({ + where: { id: document.documentMeta?.id }, + data: { lastReminderSentAt: now }, + }); + } + }, +} as const satisfies JobDefinition; diff --git a/packages/lib/server-only/document-meta/upsert-document-meta.ts b/packages/lib/server-only/document-meta/upsert-document-meta.ts index 22283ca2d..fff91efe2 100644 --- a/packages/lib/server-only/document-meta/upsert-document-meta.ts +++ b/packages/lib/server-only/document-meta/upsert-document-meta.ts @@ -7,7 +7,11 @@ import { diffDocumentMetaChanges, } from '@documenso/lib/utils/document-audit-logs'; import { prisma } from '@documenso/prisma'; -import type { DocumentDistributionMethod, DocumentSigningOrder } from '@documenso/prisma/client'; +import type { + DocumentDistributionMethod, + DocumentSigningOrder, + ReminderInterval, +} from '@documenso/prisma/client'; import type { SupportedLanguageCodes } from '../../constants/i18n'; import type { TDocumentEmailSettings } from '../../types/document-email'; @@ -24,7 +28,7 @@ export type CreateDocumentMetaOptions = { signingOrder?: DocumentSigningOrder; distributionMethod?: DocumentDistributionMethod; typedSignatureEnabled?: boolean; - reminderDays?: number; + reminderInterval?: ReminderInterval; language?: SupportedLanguageCodes; userId: number; requestMetadata: RequestMetadata; @@ -43,7 +47,7 @@ export const upsertDocumentMeta = async ({ emailSettings, distributionMethod, typedSignatureEnabled, - reminderDays, + reminderInterval, language, requestMetadata, }: CreateDocumentMetaOptions) => { @@ -98,7 +102,7 @@ export const upsertDocumentMeta = async ({ emailSettings, distributionMethod, typedSignatureEnabled, - reminderDays, + reminderInterval, language, }, update: { @@ -112,7 +116,7 @@ export const upsertDocumentMeta = async ({ emailSettings, distributionMethod, typedSignatureEnabled, - reminderDays, + reminderInterval, language, }, }); 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 81655b965..070d91485 100644 --- a/packages/lib/server-only/document/complete-document-with-token.ts +++ b/packages/lib/server-only/document/complete-document-with-token.ts @@ -178,6 +178,10 @@ export const completeDocumentWithToken = async ({ requestMetadata, }, }); + + // TODO: Duncan -- trigger cron job to send reminder email + // TODO: Duncan -- audit log + // TODO: Trigger cron job if cron is activated }); } } diff --git a/packages/lib/utils/should-send-reminder.ts b/packages/lib/utils/should-send-reminder.ts new file mode 100644 index 000000000..5a4defcc0 --- /dev/null +++ b/packages/lib/utils/should-send-reminder.ts @@ -0,0 +1,49 @@ +import { DateTime } from 'luxon'; + +import { ReminderInterval } from '@documenso/prisma/client'; + +export type ShouldSendReminderOptions = { + reminderInterval: ReminderInterval; + lastReminderSentAt: Date | null; + now: Date; +}; + +export const shouldSendReminder = ({ + lastReminderSentAt, + now = new Date(), + reminderInterval, +}: ShouldSendReminderOptions): boolean => { + if (!lastReminderSentAt) { + return true; + } + + const hoursSinceLastReminder = DateTime.fromJSDate(now).diff( + DateTime.fromJSDate(lastReminderSentAt), + 'hours', + ).hours; + const monthsSinceLastReminder = DateTime.fromJSDate(now).diff( + DateTime.fromJSDate(lastReminderSentAt), + 'months', + ).months; + + switch (reminderInterval) { + case ReminderInterval.EVERY_1_HOUR: + return hoursSinceLastReminder >= 1; + case ReminderInterval.EVERY_6_HOURS: + return hoursSinceLastReminder >= 6; + case ReminderInterval.EVERY_12_HOURS: + return hoursSinceLastReminder >= 12; + case ReminderInterval.DAILY: + return hoursSinceLastReminder >= 24; + case ReminderInterval.EVERY_3_DAYS: + return hoursSinceLastReminder >= 72; + case ReminderInterval.WEEKLY: + return hoursSinceLastReminder >= 168; + case ReminderInterval.EVERY_2_WEEKS: + return hoursSinceLastReminder >= 336; + case ReminderInterval.MONTHLY: + return monthsSinceLastReminder >= 1; + default: + return false; + } +}; diff --git a/packages/prisma/migrations/20241121001411_add_reminder_interval_enum/migration.sql b/packages/prisma/migrations/20241121001411_add_reminder_interval_enum/migration.sql new file mode 100644 index 000000000..08b7f7c91 --- /dev/null +++ b/packages/prisma/migrations/20241121001411_add_reminder_interval_enum/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - You are about to drop the column `reminderDays` on the `DocumentMeta` table. All the data in the column will be lost. + +*/ +-- CreateEnum +CREATE TYPE "ReminderInterval" AS ENUM ('NONE', 'EVERY_1_HOUR', 'EVERY_6_HOURS', 'EVERY_12_HOURS', 'DAILY', 'EVERY_3_DAYS', 'WEEKLY', 'EVERY_2_WEEKS', 'MONTHLY'); + +-- AlterTable +ALTER TABLE "DocumentMeta" DROP COLUMN "reminderDays", +ADD COLUMN "reminderInterval" "ReminderInterval" NOT NULL DEFAULT 'NONE'; diff --git a/packages/prisma/migrations/20241121112709_add_last_reminder_sent_date/migration.sql b/packages/prisma/migrations/20241121112709_add_last_reminder_sent_date/migration.sql new file mode 100644 index 000000000..1e1cdd6ec --- /dev/null +++ b/packages/prisma/migrations/20241121112709_add_last_reminder_sent_date/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "DocumentMeta" ADD COLUMN "lastReminderSentAt" TIMESTAMP(3); diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 73d87c5fa..de797437b 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -363,6 +363,18 @@ enum DocumentDistributionMethod { NONE } +enum ReminderInterval { + NONE + EVERY_1_HOUR + EVERY_6_HOURS + EVERY_12_HOURS + DAILY + EVERY_3_DAYS + WEEKLY + EVERY_2_WEEKS + MONTHLY +} + model DocumentMeta { id String @id @default(cuid()) subject String? @@ -378,7 +390,8 @@ model DocumentMeta { language String @default("en") distributionMethod DocumentDistributionMethod @default(EMAIL) emailSettings Json? - reminderDays Int? @default(0) + reminderInterval ReminderInterval @default(NONE) + lastReminderSentAt DateTime? } enum ReadStatus { diff --git a/packages/trpc/server/document-router/router.ts b/packages/trpc/server/document-router/router.ts index 7a456248f..11b8e374b 100644 --- a/packages/trpc/server/document-router/router.ts +++ b/packages/trpc/server/document-router/router.ts @@ -264,7 +264,7 @@ export const documentRouter = router({ meta.dateFormat || meta.redirectUrl || meta.language || - meta.reminderDays + meta.reminderInterval ) { await upsertDocumentMeta({ documentId, @@ -272,7 +272,7 @@ export const documentRouter = router({ timezone: meta.timezone, redirectUrl: meta.redirectUrl, language: meta.language, - reminderDays: meta.reminderDays, + reminderInterval: meta.reminderInterval, userId: ctx.user.id, requestMetadata, }); @@ -428,7 +428,7 @@ export const documentRouter = router({ meta.redirectUrl || meta.distributionMethod || meta.emailSettings || - meta.reminderDays + meta.reminderInterval ) { await upsertDocumentMeta({ documentId, @@ -438,7 +438,7 @@ export const documentRouter = router({ timezone: meta.timezone, redirectUrl: meta.redirectUrl, distributionMethod: meta.distributionMethod, - reminderDays: meta.reminderDays, + reminderInterval: meta.reminderInterval, userId: ctx.user.id, emailSettings: meta.emailSettings, requestMetadata: extractNextApiRequestMetadata(ctx.req), diff --git a/packages/trpc/server/document-router/schema.ts b/packages/trpc/server/document-router/schema.ts index 73ce05c75..e14f954e0 100644 --- a/packages/trpc/server/document-router/schema.ts +++ b/packages/trpc/server/document-router/schema.ts @@ -16,6 +16,7 @@ import { DocumentVisibility, FieldType, RecipientRole, + ReminderInterval, } from '@documenso/prisma/client'; export const ZFindDocumentsQuerySchema = ZBaseTableSearchParamsSchema.extend({ @@ -98,7 +99,7 @@ export const ZSetSettingsForDocumentMutationSchema = z.object({ 'Please enter a valid URL, make sure you include http:// or https:// part of the url.', }), language: z.enum(SUPPORTED_LANGUAGE_CODES).optional(), - reminderDays: z.number().optional(), + reminderInterval: z.nativeEnum(ReminderInterval).optional().default(ReminderInterval.NONE), }), }); @@ -168,7 +169,7 @@ export const ZSendDocumentMutationSchema = z.object({ 'Please enter a valid URL, make sure you include http:// or https:// part of the url.', }), emailSettings: ZDocumentEmailSettingsSchema.optional(), - reminderDays: z.number().optional(), + reminderInterval: z.nativeEnum(ReminderInterval).optional().default(ReminderInterval.NONE), }), }); diff --git a/packages/ui/primitives/document-flow/add-settings.tsx b/packages/ui/primitives/document-flow/add-settings.tsx index 2afb053b7..9b6f70741 100644 --- a/packages/ui/primitives/document-flow/add-settings.tsx +++ b/packages/ui/primitives/document-flow/add-settings.tsx @@ -13,6 +13,7 @@ import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth'; import type { TeamMemberRole } from '@documenso/prisma/client'; import { DocumentStatus, type Field, type Recipient, SendStatus } from '@documenso/prisma/client'; +import { ReminderInterval } from '@documenso/prisma/generated/types'; import type { DocumentWithData } from '@documenso/prisma/types/document-with-data'; import { DocumentGlobalAuthAccessSelect, @@ -100,7 +101,7 @@ export const AddSettingsFormPartial = ({ ?.value ?? DEFAULT_DOCUMENT_DATE_FORMAT, redirectUrl: document.documentMeta?.redirectUrl ?? '', language: document.documentMeta?.language ?? 'en', - reminderDays: document.documentMeta?.reminderDays ?? 0, + reminderInterval: document.documentMeta?.reminderInterval ?? ReminderInterval.NONE, }, }, }); @@ -394,32 +395,50 @@ export const AddSettingsFormPartial = ({ ( - Reminder{' '} + Reminder Interval{' '} - - Set the number of days between reminders for this document. - + Set the interval between reminders for this document. - field.onChange(parseInt(e.target.value, 10))} - /> + diff --git a/packages/ui/primitives/document-flow/add-settings.types.ts b/packages/ui/primitives/document-flow/add-settings.types.ts index 66b690188..1c117fedf 100644 --- a/packages/ui/primitives/document-flow/add-settings.types.ts +++ b/packages/ui/primitives/document-flow/add-settings.types.ts @@ -9,6 +9,7 @@ import { } from '@documenso/lib/types/document-auth'; import { isValidRedirectUrl } from '@documenso/lib/utils/is-valid-redirect-url'; import { DocumentVisibility } from '@documenso/prisma/client'; +import { ReminderInterval } from '@documenso/prisma/generated/types'; export const ZMapNegativeOneToUndefinedSchema = z .string() @@ -45,7 +46,7 @@ export const ZAddSettingsFormSchema = z.object({ .union([z.string(), z.enum(SUPPORTED_LANGUAGE_CODES)]) .optional() .default('en'), - reminderDays: z.number().optional(), + reminderInterval: z.nativeEnum(ReminderInterval).optional().default(ReminderInterval.NONE), }), });