diff --git a/packages/lib/jobs/client.ts b/packages/lib/jobs/client.ts index 28c712da6..62ff057c4 100644 --- a/packages/lib/jobs/client.ts +++ b/packages/lib/jobs/client.ts @@ -1,6 +1,12 @@ import { JobClient } from './client/client'; -import { registerJobs } from './definitions'; +import { SEND_CONFIRMATION_EMAIL_JOB_DEFINITION } from './definitions/send-confirmation-email'; +import { SEND_SIGNING_EMAIL_JOB_DEFINITION } from './definitions/send-signing-email'; -export const jobsClient = JobClient.getInstance(); - -registerJobs(jobsClient); +/** + * The `as const` assertion is load bearing as it provides the correct level of type inference for + * triggering jobs. + */ +export const jobsClient = new JobClient([ + SEND_CONFIRMATION_EMAIL_JOB_DEFINITION, + SEND_SIGNING_EMAIL_JOB_DEFINITION, +] as const); diff --git a/packages/lib/jobs/client/_internal/job.ts b/packages/lib/jobs/client/_internal/job.ts index 7e0a202bf..f68cf40b7 100644 --- a/packages/lib/jobs/client/_internal/job.ts +++ b/packages/lib/jobs/client/_internal/job.ts @@ -2,36 +2,47 @@ import { z } from 'zod'; import type { Json } from './json'; -export const ZTriggerJobOptionsSchema = z.object({ +export type SimpleTriggerJobOptions = { + id?: string; + name: string; + payload: unknown; + timestamp?: number; +}; + +export const ZSimpleTriggerJobOptionsSchema = z.object({ id: z.string().optional(), name: z.string(), payload: z.unknown().refine((x) => x !== undefined, { message: 'payload is required' }), timestamp: z.number().optional(), }); -// The Omit is a temporary workaround for a "bug" in the zod library -// @see: https://github.com/colinhacks/zod/issues/2966 -export type TriggerJobOptions = Omit, 'payload'> & { - payload: unknown; -}; +// Map the array to create a union of objects we may accept +export type TriggerJobOptions = []> = { + [K in keyof Definitions]: { + id?: string; + name: Definitions[K]['trigger']['name']; + payload: Definitions[K]['trigger']['schema'] extends z.ZodType ? Shape : unknown; + timestamp?: number; + }; +}[number]; // eslint-disable-next-line @typescript-eslint/no-explicit-any -export type JobDefinition = { +export type JobDefinition = { id: string; name: string; version: string; enabled?: boolean; trigger: { - name: string; - schema?: z.ZodSchema; + name: Name; + schema?: z.ZodType; }; - handler: (options: { payload: T; io: JobRunIO }) => Promise; + handler: (options: { payload: Schema; io: JobRunIO }) => Promise; }; export interface JobRunIO { // stableRun(cacheKey: string, callback: (io: JobRunIO) => T | Promise): Promise; runTask(cacheKey: string, callback: () => Promise): Promise; - triggerJob(cacheKey: string, options: TriggerJobOptions): Promise; + triggerJob(cacheKey: string, options: SimpleTriggerJobOptions): Promise; wait(cacheKey: string, ms: number): Promise; logger: { info(...args: unknown[]): void; @@ -41,3 +52,7 @@ export interface JobRunIO { log(...args: unknown[]): void; }; } + +export const defineJob = ( + job: JobDefinition, +): JobDefinition => job; diff --git a/packages/lib/jobs/client/base.ts b/packages/lib/jobs/client/base.ts index 5dfc413a1..d50ba3bc2 100644 --- a/packages/lib/jobs/client/base.ts +++ b/packages/lib/jobs/client/base.ts @@ -1,15 +1,15 @@ import type { NextApiRequest, NextApiResponse } from 'next'; -import type { JobDefinition, TriggerJobOptions } from './_internal/job'; +import type { JobDefinition, SimpleTriggerJobOptions } from './_internal/job'; export abstract class BaseJobProvider { // eslint-disable-next-line @typescript-eslint/require-await - public async triggerJob(_options: TriggerJobOptions): Promise { + public async triggerJob(_options: SimpleTriggerJobOptions): Promise { throw new Error('Not implemented'); } // eslint-disable-next-line @typescript-eslint/require-await - public defineJob(_job: JobDefinition): void { + public defineJob(_job: JobDefinition): void { throw new Error('Not implemented'); } diff --git a/packages/lib/jobs/client/client.ts b/packages/lib/jobs/client/client.ts index bedfcc3c3..2455459df 100644 --- a/packages/lib/jobs/client/client.ts +++ b/packages/lib/jobs/client/client.ts @@ -3,12 +3,12 @@ import type { BaseJobProvider as JobClientProvider } from './base'; import { LocalJobProvider } from './local'; import { TriggerJobProvider } from './trigger'; -export class JobClient { +export class JobClient = []> { private static _instance: JobClient; private _provider: JobClientProvider; - private constructor() { + public constructor(definitions: T) { if (process.env.NEXT_PRIVATE_JOBS_PROVIDER === 'trigger') { this._provider = TriggerJobProvider.getInstance(); @@ -16,23 +16,27 @@ export class JobClient { } this._provider = LocalJobProvider.getInstance(); + + definitions.forEach((definition) => { + this._provider.defineJob(definition); + }); } - public static getInstance() { - if (!this._instance) { - this._instance = new JobClient(); - } + // public static getInstance() { + // if (!this._instance) { + // this._instance = new JobClient(); + // } - return this._instance; - } + // return this._instance; + // } - public async triggerJob(options: TriggerJobOptions) { + public async triggerJob(options: TriggerJobOptions) { return this._provider.triggerJob(options); } - public defineJob(job: JobDefinition) { - return this._provider.defineJob(job); - } + // public defineJob(job: JobDefinition) { + // return this._provider.defineJob(job); + // } public getApiHandler() { return this._provider.getApiHandler(); diff --git a/packages/lib/jobs/client/local.ts b/packages/lib/jobs/client/local.ts index 6a7f99c60..8f368c404 100644 --- a/packages/lib/jobs/client/local.ts +++ b/packages/lib/jobs/client/local.ts @@ -12,8 +12,8 @@ import { verify } from '../../server-only/crypto/verify'; import { type JobDefinition, type JobRunIO, - type TriggerJobOptions, - ZTriggerJobOptionsSchema, + type SimpleTriggerJobOptions, + ZSimpleTriggerJobOptionsSchema, } from './_internal/job'; import type { Json } from './_internal/json'; import { BaseJobProvider } from './base'; @@ -35,14 +35,14 @@ export class LocalJobProvider extends BaseJobProvider { return this._instance; } - public defineJob(definition: JobDefinition) { + public defineJob(definition: JobDefinition) { this._jobDefinitions[definition.id] = { ...definition, enabled: definition.enabled ?? true, }; } - public async triggerJob(options: TriggerJobOptions) { + public async triggerJob(options: SimpleTriggerJobOptions) { console.log({ jobDefinitions: this._jobDefinitions }); const eligibleJobs = Object.values(this._jobDefinitions).filter( @@ -87,9 +87,9 @@ export class LocalJobProvider extends BaseJobProvider { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions const options = await json(req) - .then(async (data) => ZTriggerJobOptionsSchema.parseAsync(data)) + .then(async (data) => ZSimpleTriggerJobOptionsSchema.parseAsync(data)) // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - .then((data) => data as TriggerJobOptions) + .then((data) => data as SimpleTriggerJobOptions) .catch(() => null); if (!options) { @@ -224,7 +224,7 @@ export class LocalJobProvider extends BaseJobProvider { private async submitJobToEndpoint(options: { jobId: string; jobDefinitionId: string; - data: TriggerJobOptions; + data: SimpleTriggerJobOptions; isRetry?: boolean; }) { const { jobId, jobDefinitionId, data, isRetry } = options; diff --git a/packages/lib/jobs/client/trigger.ts b/packages/lib/jobs/client/trigger.ts index 40fbc1e83..d5545e781 100644 --- a/packages/lib/jobs/client/trigger.ts +++ b/packages/lib/jobs/client/trigger.ts @@ -4,7 +4,7 @@ import { createPagesRoute } from '@trigger.dev/nextjs'; import type { IO } from '@trigger.dev/sdk'; import { TriggerClient, eventTrigger } from '@trigger.dev/sdk'; -import type { JobDefinition, JobRunIO, TriggerJobOptions } from './_internal/job'; +import type { JobDefinition, JobRunIO, SimpleTriggerJobOptions } from './_internal/job'; import { BaseJobProvider } from './base'; export class TriggerJobProvider extends BaseJobProvider { @@ -32,7 +32,7 @@ export class TriggerJobProvider extends BaseJobProvider { return this._instance; } - public defineJob(job: JobDefinition): void { + public defineJob(job: JobDefinition): void { this._client.defineJob({ id: job.id, name: job.name, @@ -45,12 +45,12 @@ export class TriggerJobProvider extends BaseJobProvider { }); } - public async triggerJob(_options: TriggerJobOptions): Promise { + public async triggerJob(options: SimpleTriggerJobOptions): Promise { await this._client.sendEvent({ - id: _options.id, - name: _options.name, - payload: _options.payload, - timestamp: _options.timestamp ? new Date(_options.timestamp) : undefined, + id: options.id, + name: options.name, + payload: options.payload, + timestamp: options.timestamp ? new Date(options.timestamp) : undefined, }); } diff --git a/packages/lib/jobs/definitions/index.ts b/packages/lib/jobs/definitions/index.ts deleted file mode 100644 index 9d1b0a78b..000000000 --- a/packages/lib/jobs/definitions/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { JobClient } from '../client/client'; -import { registerSendConfirmationEmailJob } from './send-confirmation-email'; - -export const registerJobs = (client: JobClient) => { - registerSendConfirmationEmailJob(client); -}; diff --git a/packages/lib/jobs/definitions/send-confirmation-email.ts b/packages/lib/jobs/definitions/send-confirmation-email.ts index 692f3ab1a..7da5a88f1 100644 --- a/packages/lib/jobs/definitions/send-confirmation-email.ts +++ b/packages/lib/jobs/definitions/send-confirmation-email.ts @@ -1,25 +1,23 @@ import { z } from 'zod'; import { sendConfirmationToken } from '../../server-only/user/send-confirmation-token'; -import type { JobClient } from '../client/client'; +import type { JobDefinition } from '../client/_internal/job'; -export const registerSendConfirmationEmailJob = (client: JobClient) => { - client.defineJob({ - id: 'send.confirmation.email', - name: 'Send Confirmation Email', - version: '1.0.0', - trigger: { - name: 'send.confirmation.email', - schema: z.object({ - email: z.string().email(), - force: z.boolean().optional(), - }), - }, - handler: async ({ payload }) => { - await sendConfirmationToken({ - email: payload.email, - force: payload.force, - }); - }, - }); -}; +export const SEND_CONFIRMATION_EMAIL_JOB_DEFINITION = { + id: 'send.confirmation.email', + name: 'Send Confirmation Email', + version: '1.0.0', + trigger: { + name: 'send.confirmation.email', + schema: z.object({ + email: z.string().email(), + force: z.boolean().optional(), + }), + }, + handler: async ({ payload }) => { + await sendConfirmationToken({ + email: payload.email, + force: payload.force, + }); + }, +} as const satisfies JobDefinition; diff --git a/packages/lib/jobs/definitions/send-signing-email.ts b/packages/lib/jobs/definitions/send-signing-email.ts new file mode 100644 index 000000000..701831648 --- /dev/null +++ b/packages/lib/jobs/definitions/send-signing-email.ts @@ -0,0 +1,149 @@ +import { createElement } from 'react'; + +import { z } from 'zod'; + +import { mailer } from '@documenso/email/mailer'; +import { render } from '@documenso/email/render'; +import DocumentInviteEmailTemplate from '@documenso/email/templates/document-invite'; +import { prisma } from '@documenso/prisma'; +import { DocumentStatus, RecipientRole, SendStatus } from '@documenso/prisma/client'; + +import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; +import { FROM_ADDRESS, FROM_NAME } from '../../constants/email'; +import { + RECIPIENT_ROLES_DESCRIPTION, + RECIPIENT_ROLE_TO_EMAIL_TYPE, +} from '../../constants/recipient-roles'; +import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs'; +import { createDocumentAuditLogData } from '../../utils/document-audit-logs'; +import { renderCustomEmailTemplate } from '../../utils/render-custom-email-template'; +import type { JobDefinition } from '../client/_internal/job'; +import { ZRequestMetadataSchema } from '../../universal/extract-request-metadata'; + +export const SEND_SIGNING_EMAIL_JOB_DEFINITION = { + id: 'send.signing.email', + name: 'Send Signing Email', + version: '1.0.0', + trigger: { + name: 'send.signing.email', + schema: z.object({ + userId: z.number(), + documentId: z.number(), + recipientId: z.number(), + requestMetadata: ZRequestMetadataSchema.optional(), + }), + }, + handler: async ({ payload, io }) => { + const { userId, documentId, recipientId, requestMetadata } = payload; + + const [user, document, recipient] = await Promise.all([ + prisma.user.findFirstOrThrow({ + where: { + id: userId, + }, + }), + prisma.document.findFirstOrThrow({ + where: { + id: documentId, + status: DocumentStatus.PENDING, + }, + include: { + documentMeta: true, + }, + }), + prisma.recipient.findFirstOrThrow({ + where: { + id: recipientId, + }, + }), + ]); + + const { documentMeta } = document; + + if (recipient.role === RecipientRole.CC) { + return; + } + + const emailType = RECIPIENT_ROLE_TO_EMAIL_TYPE[recipient.role]; + const roleDescription = RECIPIENT_ROLES_DESCRIPTION[recipient.role]; + + const isSendingToSelf = recipient.email === user.email; + const selfSignerMessage = `You have initiated the document ${`"${document.title}"`} that requires you to ${roleDescription.actionVerb.toLowerCase()} it.`; + + const emailSubject = isSendingToSelf + ? `Please ${roleDescription.actionVerb.toLowerCase()} your document` + : `Please ${roleDescription.actionVerb.toLowerCase()} this document`; + + const emailTemplateVariables = { + 'signer.name': recipient.name, + 'signer.email': recipient.email, + 'document.title': document.title, + }; + + const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000'; + const signDocumentUrl = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`; + + const template = createElement(DocumentInviteEmailTemplate, { + documentName: document.title, + inviterName: user.name || undefined, + inviterEmail: user.email, + assetBaseUrl, + signDocumentLink: signDocumentUrl, + customBody: renderCustomEmailTemplate( + isSendingToSelf ? selfSignerMessage : documentMeta?.message || '', + emailTemplateVariables, + ), + role: recipient.role, + selfSigner: isSendingToSelf, + }); + + await io.runTask('send-signing-email', async () => { + await mailer.sendMail({ + to: { + name: recipient.name, + address: recipient.email, + }, + from: { + name: FROM_NAME, + address: FROM_ADDRESS, + }, + subject: renderCustomEmailTemplate( + documentMeta?.subject || emailSubject, + emailTemplateVariables, + ), + html: render(template), + text: render(template, { plainText: true }), + }); + }); + + await io.runTask('update-recipient', async () => { + await prisma.recipient.update({ + where: { + id: recipient.id, + }, + data: { + sendStatus: SendStatus.SENT, + }, + }); + }); + + await io.runTask('store-audit-log', async () => { + await prisma.documentAuditLog.create({ + data: createDocumentAuditLogData({ + type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT, + documentId: document.id, + user, + requestMetadata, + data: { + emailType, + recipientId: recipient.id, + recipientName: recipient.name, + recipientEmail: recipient.email, + recipientRole: recipient.role, + isResending: false, + }, + }), + }); + }); + }, +} as const satisfies JobDefinition; diff --git a/packages/lib/server-only/document/send-document.tsx b/packages/lib/server-only/document/send-document.tsx index 3697f88fc..83007e78a 100644 --- a/packages/lib/server-only/document/send-document.tsx +++ b/packages/lib/server-only/document/send-document.tsx @@ -1,16 +1,9 @@ -import { createElement } from 'react'; - -import { mailer } from '@documenso/email/mailer'; -import { render } from '@documenso/email/render'; -import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite'; -import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email'; import { sealDocument } from '@documenso/lib/server-only/document/seal-document'; import { updateDocument } from '@documenso/lib/server-only/document/update-document'; import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { putPdfFile } from '@documenso/lib/universal/upload/put-file'; import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs'; -import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template'; import { prisma } from '@documenso/prisma'; import { DocumentSource, @@ -21,11 +14,7 @@ import { } from '@documenso/prisma/client'; import { WebhookTriggerEvents } from '@documenso/prisma/client'; -import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; -import { - RECIPIENT_ROLES_DESCRIPTION, - RECIPIENT_ROLE_TO_EMAIL_TYPE, -} from '../../constants/recipient-roles'; +import { jobsClient } from '../../jobs/client'; import { getFile } from '../../universal/upload/get-file'; import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf'; import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; @@ -137,92 +126,15 @@ export const sendDocument = async ({ return; } - const recipientEmailType = RECIPIENT_ROLE_TO_EMAIL_TYPE[recipient.role]; - - const { email, name } = recipient; - const selfSigner = email === user.email; - const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role]; - const recipientActionVerb = actionVerb.toLowerCase(); - - let emailMessage = customEmail?.message || ''; - let emailSubject = `Please ${recipientActionVerb} this document`; - - if (selfSigner) { - emailMessage = `You have initiated the document ${`"${document.title}"`} that requires you to ${recipientActionVerb} it.`; - emailSubject = `Please ${recipientActionVerb} your document`; - } - - if (isDirectTemplate) { - emailMessage = `A document was created by your direct template that requires you to ${recipientActionVerb} it.`; - emailSubject = `Please ${recipientActionVerb} this document created by your direct template`; - } - - const customEmailTemplate = { - 'signer.name': name, - 'signer.email': email, - 'document.name': document.title, - }; - - const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000'; - const signDocumentLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`; - - const template = createElement(DocumentInviteEmailTemplate, { - documentName: document.title, - inviterName: user.name || undefined, - inviterEmail: user.email, - assetBaseUrl, - signDocumentLink, - customBody: renderCustomEmailTemplate(emailMessage, customEmailTemplate), - role: recipient.role, - selfSigner, - }); - - await prisma.$transaction( - async (tx) => { - await mailer.sendMail({ - to: { - address: email, - name, - }, - from: { - name: FROM_NAME, - address: FROM_ADDRESS, - }, - subject: customEmail?.subject - ? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate) - : emailSubject, - html: render(template), - text: render(template, { plainText: true }), - }); - - await tx.recipient.update({ - where: { - id: recipient.id, - }, - data: { - sendStatus: SendStatus.SENT, - }, - }); - - await tx.documentAuditLog.create({ - data: createDocumentAuditLogData({ - type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT, - documentId: document.id, - user, - requestMetadata, - data: { - emailType: recipientEmailType, - recipientEmail: recipient.email, - recipientName: recipient.name, - recipientRole: recipient.role, - recipientId: recipient.id, - isResending: false, - }, - }), - }); + await jobsClient.triggerJob({ + name: 'send.signing.email', + payload: { + userId, + documentId, + recipientId: recipient.id, + requestMetadata, }, - { timeout: 30_000 }, - ); + }); }), ); } diff --git a/packages/lib/universal/extract-request-metadata.ts b/packages/lib/universal/extract-request-metadata.ts index d608d5f80..3129a060b 100644 --- a/packages/lib/universal/extract-request-metadata.ts +++ b/packages/lib/universal/extract-request-metadata.ts @@ -5,10 +5,12 @@ import { z } from 'zod'; const ZIpSchema = z.string().ip(); -export type RequestMetadata = { - ipAddress?: string; - userAgent?: string; -}; +export const ZRequestMetadataSchema = z.object({ + ipAddress: ZIpSchema.optional(), + userAgent: z.string().optional(), +}); + +export type RequestMetadata = z.infer; export const extractNextApiRequestMetadata = (req: NextApiRequest): RequestMetadata => { const parsedIp = ZIpSchema.safeParse(req.headers['x-forwarded-for'] || req.socket.remoteAddress);