From 7c234edf8747de5abe3a12d855a566b5e15edc26 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Tue, 15 Apr 2025 09:22:49 +0000 Subject: [PATCH] feat: add daily, hourly, weekly, and monthly reminder email jobs; remove signing reminder job --- packages/lib/jobs/client.ts | 10 +- packages/lib/jobs/client/inngest.ts | 5 +- .../emails/send-daily-reminder-email.ts | 24 +++ .../emails/send-hourly-reminder-email.ts | 25 +++ .../emails/send-monthly-reminder-email.ts | 24 +++ .../emails/send-reminder.handler.ts | 182 ++++++++++++++++++ .../emails/send-signing-reminder-email.ts | 169 ---------------- .../emails/send-weekly-reminder-email.ts | 24 +++ 8 files changed, 291 insertions(+), 172 deletions(-) create mode 100644 packages/lib/jobs/definitions/emails/send-daily-reminder-email.ts create mode 100644 packages/lib/jobs/definitions/emails/send-hourly-reminder-email.ts create mode 100644 packages/lib/jobs/definitions/emails/send-monthly-reminder-email.ts create mode 100644 packages/lib/jobs/definitions/emails/send-reminder.handler.ts delete mode 100644 packages/lib/jobs/definitions/emails/send-signing-reminder-email.ts create mode 100644 packages/lib/jobs/definitions/emails/send-weekly-reminder-email.ts diff --git a/packages/lib/jobs/client.ts b/packages/lib/jobs/client.ts index c0e5aeb55..b8cea4bde 100644 --- a/packages/lib/jobs/client.ts +++ b/packages/lib/jobs/client.ts @@ -1,14 +1,17 @@ import { JobClient } from './client/client'; import { SEND_CONFIRMATION_EMAIL_JOB_DEFINITION } from './definitions/emails/send-confirmation-email'; +import { SEND_DAILY_REMINDER_EMAIL_JOB } from './definitions/emails/send-daily-reminder-email'; import { SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION } from './definitions/emails/send-document-cancelled-emails'; +import { SEND_HOURLY_REMINDER_EMAIL_JOB } from './definitions/emails/send-hourly-reminder-email'; +import { SEND_MONTHLY_REMINDER_EMAIL_JOB } from './definitions/emails/send-monthly-reminder-email'; import { SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION } from './definitions/emails/send-password-reset-success-email'; import { SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-recipient-signed-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'; +import { SEND_WEEKLY_REMINDER_EMAIL_JOB } from './definitions/emails/send-weekly-reminder-email'; import { BULK_SEND_TEMPLATE_JOB_DEFINITION } from './definitions/internal/bulk-send-template'; import { SEAL_DOCUMENT_JOB_DEFINITION } from './definitions/internal/seal-document'; @@ -28,7 +31,10 @@ export const jobsClient = new JobClient([ SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION, SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION, BULK_SEND_TEMPLATE_JOB_DEFINITION, - SEND_SIGNING_REMINDER_EMAIL_JOB, + SEND_HOURLY_REMINDER_EMAIL_JOB, + SEND_DAILY_REMINDER_EMAIL_JOB, + SEND_WEEKLY_REMINDER_EMAIL_JOB, + SEND_MONTHLY_REMINDER_EMAIL_JOB, ] as const); export const jobs = jobsClient; diff --git a/packages/lib/jobs/client/inngest.ts b/packages/lib/jobs/client/inngest.ts index 0c3c1595a..9dd806478 100644 --- a/packages/lib/jobs/client/inngest.ts +++ b/packages/lib/jobs/client/inngest.ts @@ -112,7 +112,10 @@ export class InngestJobProvider extends BaseJobProvider { return { wait: step.sleep, logger: { - ...ctx.logger, + info: ctx.logger.info, + error: ctx.logger.error, + warn: ctx.logger.warn, + debug: ctx.logger.debug, log: ctx.logger.info, }, runTask: async (cacheKey, callback) => { diff --git a/packages/lib/jobs/definitions/emails/send-daily-reminder-email.ts b/packages/lib/jobs/definitions/emails/send-daily-reminder-email.ts new file mode 100644 index 000000000..cd32ed4b4 --- /dev/null +++ b/packages/lib/jobs/definitions/emails/send-daily-reminder-email.ts @@ -0,0 +1,24 @@ +import { DocumentReminderInterval } from '@documenso/prisma/client'; + +import type { JobDefinition } from '../../client/_internal/job'; + +const SEND_DAILY_REMINDER_EMAIL_JOB_ID = 'send.daily.reminder.email'; + +export const SEND_DAILY_REMINDER_EMAIL_JOB = { + id: SEND_DAILY_REMINDER_EMAIL_JOB_ID, + name: 'Send Daily Reminder Email', + version: '1.0.0', + trigger: { + type: 'cron', + schedule: '0 0 * * *', + name: SEND_DAILY_REMINDER_EMAIL_JOB_ID, + }, + handler: async ({ io }) => { + const handler = await import('./send-reminder.handler'); + + await handler.run({ + io, + interval: DocumentReminderInterval.DAILY, + }); + }, +} as const satisfies JobDefinition; diff --git a/packages/lib/jobs/definitions/emails/send-hourly-reminder-email.ts b/packages/lib/jobs/definitions/emails/send-hourly-reminder-email.ts new file mode 100644 index 000000000..5a7b8af25 --- /dev/null +++ b/packages/lib/jobs/definitions/emails/send-hourly-reminder-email.ts @@ -0,0 +1,25 @@ +import { DocumentReminderInterval } from '@documenso/prisma/client'; + +import type { JobDefinition } from '../../client/_internal/job'; + +const SEND_HOURLY_REMINDER_EMAIL_JOB_ID = 'send.hourly.reminder.email'; + +export const SEND_HOURLY_REMINDER_EMAIL_JOB = { + id: SEND_HOURLY_REMINDER_EMAIL_JOB_ID, + name: 'Send Hourly Reminder Email', + version: '1.0.0', + trigger: { + type: 'cron', + // schedule: '0 * * * *', + schedule: '*/2 * * * *', + name: SEND_HOURLY_REMINDER_EMAIL_JOB_ID, + }, + handler: async ({ io }) => { + const handler = await import('./send-reminder.handler'); + + await handler.run({ + io, + interval: DocumentReminderInterval.EVERY_1_HOUR, + }); + }, +} as const satisfies JobDefinition; diff --git a/packages/lib/jobs/definitions/emails/send-monthly-reminder-email.ts b/packages/lib/jobs/definitions/emails/send-monthly-reminder-email.ts new file mode 100644 index 000000000..eeeb768ee --- /dev/null +++ b/packages/lib/jobs/definitions/emails/send-monthly-reminder-email.ts @@ -0,0 +1,24 @@ +import { DocumentReminderInterval } from '@documenso/prisma/client'; + +import type { JobDefinition } from '../../client/_internal/job'; + +const SEND_MONTHLY_REMINDER_EMAIL_JOB_ID = 'send.monthly.reminder.email'; + +export const SEND_MONTHLY_REMINDER_EMAIL_JOB = { + id: SEND_MONTHLY_REMINDER_EMAIL_JOB_ID, + name: 'Send Monthly Reminder Email', + version: '1.0.0', + trigger: { + type: 'cron', + schedule: '0 0 1 * *', + name: SEND_MONTHLY_REMINDER_EMAIL_JOB_ID, + }, + handler: async ({ io }) => { + const handler = await import('./send-reminder.handler'); + + await handler.run({ + io, + interval: DocumentReminderInterval.MONTHLY, + }); + }, +} as const satisfies JobDefinition; diff --git a/packages/lib/jobs/definitions/emails/send-reminder.handler.ts b/packages/lib/jobs/definitions/emails/send-reminder.handler.ts new file mode 100644 index 000000000..b051df6eb --- /dev/null +++ b/packages/lib/jobs/definitions/emails/send-reminder.handler.ts @@ -0,0 +1,182 @@ +import { createElement } from 'react'; + +import { msg } from '@lingui/core/macro'; + +import { mailer } from '@documenso/email/mailer'; +import DocumentInviteEmailTemplate from '@documenso/email/templates/document-invite'; +import { prisma } from '@documenso/prisma'; +import type { DocumentReminderInterval } from '@documenso/prisma/client'; +import { DocumentStatus, RecipientRole, SendStatus, SigningStatus } from '@documenso/prisma/client'; + +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 { JobRunIO } from '../../client/_internal/job'; + +// TODO: Add Audit Log import and usage + +export type SendReminderHandlerOptions = { + io: JobRunIO; + interval: DocumentReminderInterval; +}; + +export async function run({ io, interval }: SendReminderHandlerOptions) { + const now = new Date(); + + const documentsToSendReminders = await io.runTask( + `find-documents-for-${interval.toLocaleUpperCase()}-reminder`, + async () => { + const documents = await prisma.document.findMany({ + where: { + status: DocumentStatus.PENDING, + documentMeta: { + reminderInterval: { + equals: interval, + }, + }, + deletedAt: null, + }, + include: { + documentMeta: true, + user: true, + recipients: { + where: { + signingStatus: SigningStatus.NOT_SIGNED, + role: { + not: RecipientRole.CC, + }, + }, + }, + }, + }); + + const filteredDocuments = documents.filter((document) => { + const { documentMeta } = document; + if (!documentMeta) { + io.logger.warn(`Filtering out document ${document.id} due to missing documentMeta.`); + return false; + } + + const { reminderInterval, lastReminderSentAt } = documentMeta; + const shouldSend = shouldSendReminder({ + reminderInterval, + lastReminderSentAt, + now, + }); + + return shouldSend; + }); + + io.logger.info( + `Found ${filteredDocuments.length} documents after filtering for interval ${interval}.`, + filteredDocuments.map((d) => ({ id: d.id })), + ); + + return filteredDocuments; + }, + ); + + if (documentsToSendReminders.length === 0) { + io.logger.info(`No documents found needing ${interval.toLocaleUpperCase()} reminders.`); + return; + } + + io.logger.info( + `Found ${documentsToSendReminders.length} documents needing ${interval.toLocaleUpperCase()} reminders.`, + ); + + for (const document of documentsToSendReminders) { + if (!document.documentMeta) { + io.logger.warn(`Skipping document ${document.id} due to missing documentMeta.`); + continue; + } + + if (!extractDerivedDocumentEmailSettings(document.documentMeta).recipientSigningRequest) { + io.logger.info(`Skipping document ${document.id} due to email settings.`); + continue; + } + + for (const recipient of document.recipients) { + try { + 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-${recipient.id}`, 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, + }); + + // Update recipient status (might be redundant if only tracking lastReminderSentAt on DocumentMeta) + await prisma.recipient.update({ + where: { id: recipient.id }, + data: { sendStatus: SendStatus.SENT }, + }); + }); + + // TODO: Duncan == Audit log + // await io.runTask(`log-reminder-${recipient.id}`, async () => { + // await prisma.documentAuditLog.create(...); + // }); + } catch (error) { + io.logger.error(`Error processing reminder for recipient ${recipient.id}`, error); + } + } + + try { + await io.runTask(`update-meta-${document.id}`, async () => { + await prisma.documentMeta.update({ + where: { documentId: document.id }, + data: { lastReminderSentAt: now }, + }); + }); + io.logger.info(`Updated lastReminderSentAt for document ${document.id}`); + } catch (error) { + io.logger.error(`Error updating lastReminderSentAt for document ${document.id}`, error); + } + } +} diff --git a/packages/lib/jobs/definitions/emails/send-signing-reminder-email.ts b/packages/lib/jobs/definitions/emails/send-signing-reminder-email.ts deleted file mode 100644 index 7a674adc6..000000000 --- a/packages/lib/jobs/definitions/emails/send-signing-reminder-email.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { createElement } from 'react'; - -import { msg } from '@lingui/core/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 { DocumentReminderInterval, 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: DocumentReminderInterval.NONE, - }, - }, - deletedAt: null, - }, - - include: { - documentMeta: true, - user: true, - recipients: { - 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.recipients) { - 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/jobs/definitions/emails/send-weekly-reminder-email.ts b/packages/lib/jobs/definitions/emails/send-weekly-reminder-email.ts new file mode 100644 index 000000000..f12845727 --- /dev/null +++ b/packages/lib/jobs/definitions/emails/send-weekly-reminder-email.ts @@ -0,0 +1,24 @@ +import { DocumentReminderInterval } from '@documenso/prisma/client'; + +import type { JobDefinition } from '../../client/_internal/job'; + +const SEND_WEEKLY_REMINDER_EMAIL_JOB_ID = 'send.weekly.reminder.email'; + +export const SEND_WEEKLY_REMINDER_EMAIL_JOB = { + id: SEND_WEEKLY_REMINDER_EMAIL_JOB_ID, + name: 'Send Weekly Reminder Email', + version: '1.0.0', + trigger: { + type: 'cron', + schedule: '0 0 * * 0', + name: SEND_WEEKLY_REMINDER_EMAIL_JOB_ID, + }, + handler: async ({ io }) => { + const handler = await import('./send-reminder.handler'); + + await handler.run({ + io, + interval: DocumentReminderInterval.WEEKLY, + }); + }, +} as const satisfies JobDefinition;