diff --git a/packages/lib/jobs/client.ts b/packages/lib/jobs/client.ts index 713d928d8..c0e5aeb55 100644 --- a/packages/lib/jobs/client.ts +++ b/packages/lib/jobs/client.ts @@ -5,6 +5,7 @@ import { SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION } from './definitions/ 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'; @@ -27,6 +28,7 @@ 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, ] 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 dce2f652b..d0fbf08e7 100644 --- a/packages/lib/jobs/client/_internal/job.ts +++ b/packages/lib/jobs/client/_internal/job.ts @@ -26,16 +26,26 @@ export type TriggerJobOptions = }; }[number]; +export type CronTrigger = { + type: 'cron'; + schedule: string; + name: N; +}; + +export type EventTrigger = { + type: 'event'; + name: N; +}; + // eslint-disable-next-line @typescript-eslint/no-explicit-any export type JobDefinition = { id: string; name: string; version: string; enabled?: boolean; - trigger: { - name: Name; - schema?: z.ZodType; - }; + trigger: + | (EventTrigger & { schema?: z.ZodType }) + | (CronTrigger & { schema?: z.ZodType }); handler: (options: { payload: Schema; io: JobRunIO }) => Promise; }; diff --git a/packages/lib/jobs/client/inngest.ts b/packages/lib/jobs/client/inngest.ts index 95f5d5a6e..0c3c1595a 100644 --- a/packages/lib/jobs/client/inngest.ts +++ b/packages/lib/jobs/client/inngest.ts @@ -35,29 +35,53 @@ export class InngestJobProvider extends BaseJobProvider { } public defineJob(job: JobDefinition): void { - console.log('defining job', job.id); - const fn = this._client.createFunction( - { - id: job.id, - name: job.name, - }, - { - event: job.trigger.name, - }, - async (ctx) => { - const io = this.convertInngestIoToJobRunIo(ctx); + let fn: InngestFunction.Any; - // We need to cast to any so we can deal with parsing later. - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any - let payload = ctx.event.data as any; + if (job.trigger.type === 'cron') { + fn = this._client.createFunction( + { + id: job.id, + name: job.name, + }, + { + cron: job.trigger.schedule, + }, + async (ctx) => { + const io = this.convertInngestIoToJobRunIo(ctx); + const payload: T | undefined = undefined; - if (job.trigger.schema) { - payload = job.trigger.schema.parse(payload); - } + if (job.trigger.schema) { + console.warn( + `Job "${job.id}" is cron-triggered but defines a schema. The schema will be ignored. `, + ); + } - await job.handler({ payload, io }); - }, - ); + await job.handler({ payload: payload as T, io }); + }, + ); + } else { + fn = this._client.createFunction( + { + id: job.id, + name: job.name, + }, + { + event: job.trigger.name, + }, + async (ctx) => { + const io = this.convertInngestIoToJobRunIo(ctx); + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any + let payload = ctx.event.data as any; + + if (job.trigger.schema) { + payload = job.trigger.schema.parse(payload); + } + + await job.handler({ payload, io }); + }, + ); + } this._functions.push(fn); } diff --git a/packages/lib/jobs/definitions/emails/send-confirmation-email.ts b/packages/lib/jobs/definitions/emails/send-confirmation-email.ts index ad30dd875..bbbb12b86 100644 --- a/packages/lib/jobs/definitions/emails/send-confirmation-email.ts +++ b/packages/lib/jobs/definitions/emails/send-confirmation-email.ts @@ -18,6 +18,7 @@ export const SEND_CONFIRMATION_EMAIL_JOB_DEFINITION = { name: 'Send Confirmation Email', version: '1.0.0', trigger: { + type: 'event', name: SEND_CONFIRMATION_EMAIL_JOB_DEFINITION_ID, schema: SEND_CONFIRMATION_EMAIL_JOB_DEFINITION_SCHEMA, }, diff --git a/packages/lib/jobs/definitions/emails/send-document-cancelled-emails.ts b/packages/lib/jobs/definitions/emails/send-document-cancelled-emails.ts index ac21d806e..70751e1b4 100644 --- a/packages/lib/jobs/definitions/emails/send-document-cancelled-emails.ts +++ b/packages/lib/jobs/definitions/emails/send-document-cancelled-emails.ts @@ -19,6 +19,7 @@ export const SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION = { name: 'Send Document Cancelled Emails', version: '1.0.0', trigger: { + type: 'event', name: SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION_ID, schema: SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION_SCHEMA, }, diff --git a/packages/lib/jobs/definitions/emails/send-password-reset-success-email.ts b/packages/lib/jobs/definitions/emails/send-password-reset-success-email.ts index d73246bb6..a0256575d 100644 --- a/packages/lib/jobs/definitions/emails/send-password-reset-success-email.ts +++ b/packages/lib/jobs/definitions/emails/send-password-reset-success-email.ts @@ -17,6 +17,7 @@ export const SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION = { name: 'Send Password Reset Email', version: '1.0.0', trigger: { + type: 'event', name: SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION_ID, schema: SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION_SCHEMA, }, diff --git a/packages/lib/jobs/definitions/emails/send-recipient-signed-email.ts b/packages/lib/jobs/definitions/emails/send-recipient-signed-email.ts index cb67ceed6..90a68a0b5 100644 --- a/packages/lib/jobs/definitions/emails/send-recipient-signed-email.ts +++ b/packages/lib/jobs/definitions/emails/send-recipient-signed-email.ts @@ -18,6 +18,7 @@ export const SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION = { name: 'Send Recipient Signed Email', version: '1.0.0', trigger: { + type: 'event', name: SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION_ID, schema: SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION_SCHEMA, }, diff --git a/packages/lib/jobs/definitions/emails/send-rejection-emails.ts b/packages/lib/jobs/definitions/emails/send-rejection-emails.ts index 12deffbf5..64a54b361 100644 --- a/packages/lib/jobs/definitions/emails/send-rejection-emails.ts +++ b/packages/lib/jobs/definitions/emails/send-rejection-emails.ts @@ -18,6 +18,7 @@ export const SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION = { name: 'Send Rejection Emails', version: '1.0.0', trigger: { + type: 'event', name: SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION_ID, schema: SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION_SCHEMA, }, diff --git a/packages/lib/jobs/definitions/emails/send-signing-email.ts b/packages/lib/jobs/definitions/emails/send-signing-email.ts index 9df4cf1b2..b84f884d2 100644 --- a/packages/lib/jobs/definitions/emails/send-signing-email.ts +++ b/packages/lib/jobs/definitions/emails/send-signing-email.ts @@ -21,6 +21,7 @@ export const SEND_SIGNING_EMAIL_JOB_DEFINITION = { name: 'Send Signing Email', version: '1.0.0', trigger: { + type: 'event', name: SEND_SIGNING_EMAIL_JOB_DEFINITION_ID, schema: SEND_SIGNING_EMAIL_JOB_DEFINITION_SCHEMA, }, 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..7a674adc6 --- /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/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-team-deleted-email.ts b/packages/lib/jobs/definitions/emails/send-team-deleted-email.ts index 03c32b552..1c5a66372 100644 --- a/packages/lib/jobs/definitions/emails/send-team-deleted-email.ts +++ b/packages/lib/jobs/definitions/emails/send-team-deleted-email.ts @@ -46,6 +46,7 @@ export const SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION = { name: 'Send Team Deleted Email', version: '1.0.0', trigger: { + type: 'event', name: SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION_ID, schema: SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION_SCHEMA, }, diff --git a/packages/lib/jobs/definitions/emails/send-team-member-joined-email.ts b/packages/lib/jobs/definitions/emails/send-team-member-joined-email.ts index 258e7a34e..f11a9a776 100644 --- a/packages/lib/jobs/definitions/emails/send-team-member-joined-email.ts +++ b/packages/lib/jobs/definitions/emails/send-team-member-joined-email.ts @@ -18,6 +18,7 @@ export const SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION = { name: 'Send Team Member Joined Email', version: '1.0.0', trigger: { + type: 'event', name: SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION_ID, schema: SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION_SCHEMA, }, diff --git a/packages/lib/jobs/definitions/emails/send-team-member-left-email.ts b/packages/lib/jobs/definitions/emails/send-team-member-left-email.ts index f940896a4..a79cfe738 100644 --- a/packages/lib/jobs/definitions/emails/send-team-member-left-email.ts +++ b/packages/lib/jobs/definitions/emails/send-team-member-left-email.ts @@ -18,6 +18,7 @@ export const SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION = { name: 'Send Team Member Left Email', version: '1.0.0', trigger: { + type: 'event', name: SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION_ID, schema: SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION_SCHEMA, }, diff --git a/packages/lib/jobs/definitions/internal/bulk-send-template.ts b/packages/lib/jobs/definitions/internal/bulk-send-template.ts index c101e3c40..3787057a5 100644 --- a/packages/lib/jobs/definitions/internal/bulk-send-template.ts +++ b/packages/lib/jobs/definitions/internal/bulk-send-template.ts @@ -23,6 +23,7 @@ export const BULK_SEND_TEMPLATE_JOB_DEFINITION = { name: 'Bulk Send Template', version: '1.0.0', trigger: { + type: 'event', name: BULK_SEND_TEMPLATE_JOB_DEFINITION_ID, schema: BULK_SEND_TEMPLATE_JOB_DEFINITION_SCHEMA, }, diff --git a/packages/lib/jobs/definitions/internal/seal-document.ts b/packages/lib/jobs/definitions/internal/seal-document.ts index 27d922f27..e2d48c20d 100644 --- a/packages/lib/jobs/definitions/internal/seal-document.ts +++ b/packages/lib/jobs/definitions/internal/seal-document.ts @@ -19,6 +19,7 @@ export const SEAL_DOCUMENT_JOB_DEFINITION = { name: 'Seal Document', version: '1.0.0', trigger: { + type: 'event', name: SEAL_DOCUMENT_JOB_DEFINITION_ID, schema: SEAL_DOCUMENT_JOB_DEFINITION_SCHEMA, }, diff --git a/packages/lib/utils/should-send-reminder.ts b/packages/lib/utils/should-send-reminder.ts new file mode 100644 index 000000000..91666fd14 --- /dev/null +++ b/packages/lib/utils/should-send-reminder.ts @@ -0,0 +1,49 @@ +import { DateTime } from 'luxon'; + +import { DocumentReminderInterval } from '@documenso/prisma/client'; + +export type ShouldSendReminderOptions = { + reminderInterval: DocumentReminderInterval; + 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 DocumentReminderInterval.EVERY_1_HOUR: + return hoursSinceLastReminder >= 1; + case DocumentReminderInterval.EVERY_6_HOURS: + return hoursSinceLastReminder >= 6; + case DocumentReminderInterval.EVERY_12_HOURS: + return hoursSinceLastReminder >= 12; + case DocumentReminderInterval.DAILY: + return hoursSinceLastReminder >= 24; + case DocumentReminderInterval.EVERY_3_DAYS: + return hoursSinceLastReminder >= 72; + case DocumentReminderInterval.WEEKLY: + return hoursSinceLastReminder >= 168; + case DocumentReminderInterval.EVERY_2_WEEKS: + return hoursSinceLastReminder >= 336; + case DocumentReminderInterval.MONTHLY: + return monthsSinceLastReminder >= 1; + default: + return false; + } +};