From 91d796c5211802d6c67ef0ed66facaad1a986beb Mon Sep 17 00:00:00 2001 From: lehuygiang28 Date: Sun, 7 Jul 2024 20:31:08 +0700 Subject: [PATCH] feat(be): :sparkles: send notify email when task stopped by system --- apps/be/common/src/i18n/i18n.generated.ts | 6 + .../common/src/i18n/lang/en/mail-context.json | 6 + .../src/i18n/lang/vi_VN/mail-context.json | 6 + apps/be/common/src/mail/mail.service.ts | 336 ++++++------------ .../src/mail/templates/stop-task-notify.hbs | 42 +++ apps/be/src/app/config/app-config.ts | 7 + apps/be/src/app/config/app-config.type.ts | 1 + .../app/tasks/processors/task.processor.ts | 17 +- apps/be/src/app/tasks/tasks.module.ts | 4 + 9 files changed, 201 insertions(+), 224 deletions(-) create mode 100644 apps/be/common/src/mail/templates/stop-task-notify.hbs diff --git a/apps/be/common/src/i18n/i18n.generated.ts b/apps/be/common/src/i18n/i18n.generated.ts index fc4c1c1..1e8ac08 100644 --- a/apps/be/common/src/i18n/i18n.generated.ts +++ b/apps/be/common/src/i18n/i18n.generated.ts @@ -34,6 +34,12 @@ export type I18nTranslations = { "text3": string; "btn1": string; }; + "STOP_TASK": { + "title": string; + "text1": string; + "text2": string; + "btn1": string; + }; }; }; /* prettier-ignore */ diff --git a/apps/be/common/src/i18n/lang/en/mail-context.json b/apps/be/common/src/i18n/lang/en/mail-context.json index b3c93f8..041629e 100644 --- a/apps/be/common/src/i18n/lang/en/mail-context.json +++ b/apps/be/common/src/i18n/lang/en/mail-context.json @@ -26,5 +26,11 @@ "text2": "Alternatively, you can copy the link below and paste it into your browser to get started:", "text3": "If you did not make this request, please ignore this email.", "btn1": "Login" + }, + "STOP_TASK": { + "title": "Your task has been stopped", + "text1": "Your task has been stopped due to failures.", + "text2": "Your task has been stopped by the system because of failures too many times.", + "btn1": "See Task" } } diff --git a/apps/be/common/src/i18n/lang/vi_VN/mail-context.json b/apps/be/common/src/i18n/lang/vi_VN/mail-context.json index a29310c..5dd54d3 100644 --- a/apps/be/common/src/i18n/lang/vi_VN/mail-context.json +++ b/apps/be/common/src/i18n/lang/vi_VN/mail-context.json @@ -26,5 +26,11 @@ "text2": "Hoặc bạn có thể sao chép liên kết dưới đây và dán vào trình duyệt của bạn để bắt đầu:", "text3": "Nếu bạn không yêu cầu điều này, vui lòng bỏ qua email này.", "btn1": "Đăng nhập" + }, + "STOP_TASK": { + "title": "Task của bạn đã bị dừng", + "text1": "Task của bạn đã bị dừng do xảy ra lỗi.", + "text2": "Task của bạn đã bị hệ thống dừng lại do xảy ra nhiều nhiều lỗi.", + "btn1": "Xem Task" } } diff --git a/apps/be/common/src/mail/mail.service.ts b/apps/be/common/src/mail/mail.service.ts index 1886e19..2b4ee08 100644 --- a/apps/be/common/src/mail/mail.service.ts +++ b/apps/be/common/src/mail/mail.service.ts @@ -1,17 +1,20 @@ import { Injectable } from '@nestjs/common'; -import { MailerService } from '@nestjs-modules/mailer'; +import { ISendMailOptions, MailerService } from '@nestjs-modules/mailer'; import { I18nService } from 'nestjs-i18n'; import { PinoLogger } from 'nestjs-pino'; import { Attachment } from 'nodemailer/lib/mailer'; +import { MaybeType } from '../utils/types'; import { I18nTranslations } from '../i18n'; -import type { MaybeType } from '../utils/types'; import { MailerConfig } from './mail.config'; -import { MailData } from './mail-data.interface'; import { GMAIL_TRANSPORT, RESEND_TRANSPORT, SENDGRID_TRANSPORT } from './mail.constant'; @Injectable() export class MailService { + private readonly TRANSPORTERS = [SENDGRID_TRANSPORT, RESEND_TRANSPORT, GMAIL_TRANSPORT]; + private readonly MAX_RETRIES = this.TRANSPORTERS.length; + private readonly BASE_ATTACHMENT: Attachment[] = []; + constructor( private readonly mailerService: MailerService, private readonly logger: PinoLogger, @@ -23,262 +26,149 @@ export class MailService { this.mailerService.addTransporter(GMAIL_TRANSPORT, this.mailConfig.GmailTransport); this.logger.setContext(MailService.name); - this.BASE_ATTACHMENT = [ - // { - // filename: 'giaang.png', - // path: join(__dirname, 'assets/mail/templates/assets/images/giaang.png'), - // cid: 'giaang', - // }, - ]; } - private readonly TRANSPORTERS = [SENDGRID_TRANSPORT, RESEND_TRANSPORT, GMAIL_TRANSPORT]; - private readonly MAX_RETRIES = this.TRANSPORTERS.length; - private readonly BASE_ATTACHMENT: Attachment[] = []; - private resolveTransporter(transporter = SENDGRID_TRANSPORT) { - if (!this.TRANSPORTERS.includes(transporter)) { - transporter = SENDGRID_TRANSPORT; - } - - return transporter; + return this.TRANSPORTERS.includes(transporter) ? transporter : SENDGRID_TRANSPORT; } private getNextTransporter(currentTransporter: string): string { const currentIndex = this.TRANSPORTERS.indexOf(currentTransporter); - if (currentIndex === -1 || currentIndex === this.TRANSPORTERS.length - 1) { - return this.TRANSPORTERS[0]; - } else { - return this.TRANSPORTERS[currentIndex + 1]; - } + return currentIndex === -1 || currentIndex === this.TRANSPORTERS.length - 1 + ? this.TRANSPORTERS[0] + : this.TRANSPORTERS[currentIndex + 1]; } - async sendConfirmMail( - data: { - to: string; - mailData: { - url: string; - }; - isResend?: boolean; - }, - retryData: { - retryCount?: number; - transporter?: string; - } = { retryCount: 0, transporter: SENDGRID_TRANSPORT }, + private async sendMailWithRetry( + mailData: ISendMailOptions, + retryCount = 0, + transporter = SENDGRID_TRANSPORT, ): Promise { - const { to, isResend = false, mailData } = data; - - let { transporter = SENDGRID_TRANSPORT } = retryData; - const { retryCount = 0 } = retryData; - - let emailConfirmTitle: MaybeType; - let text1: MaybeType; - let text2: MaybeType; - let text3: MaybeType; - let btn1: MaybeType; - if (retryCount > this.MAX_RETRIES) { this.logger.debug(`Send mail failed: too many retries`); - return { - message: 'Failed to send email', - }; - } - - if (isResend) { - emailConfirmTitle = this.i18n.t('mail-context.RESEND_CONFIRM_EMAIL.title'); - text1 = this.i18n.t('mail-context.RESEND_CONFIRM_EMAIL.text1'); - text2 = this.i18n.t('mail-context.RESEND_CONFIRM_EMAIL.text2'); - text3 = this.i18n.t('mail-context.RESEND_CONFIRM_EMAIL.text3'); - btn1 = this.i18n.t('mail-context.RESEND_CONFIRM_EMAIL.btn1'); - } else { - emailConfirmTitle = this.i18n.t('mail-context.CONFIRM_EMAIL.title'); - text1 = this.i18n.t('mail-context.CONFIRM_EMAIL.text1'); - text2 = this.i18n.t('mail-context.CONFIRM_EMAIL.text2'); - text3 = this.i18n.t('mail-context.CONFIRM_EMAIL.text3'); - btn1 = this.i18n.t('mail-context.CONFIRM_EMAIL.btn1'); + return { message: 'Failed to send email' }; } const transporterName = this.resolveTransporter(transporter); - this.logger.debug(`Sending confirm mail to ${to} with transporter: ${transporterName}`); - return this.mailerService - .sendMail({ + this.logger.debug(`Sending mail to ${mailData.to} with transporter: ${transporterName}`); + + try { + await this.mailerService.sendMail({ + ...mailData, transporterName, - to: to, - subject: emailConfirmTitle, - template: 'confirm-email', attachments: this.BASE_ATTACHMENT, - context: { - title: emailConfirmTitle, - url: mailData.url.toString(), - text1, - text2, - text3, - btn1, - }, - }) - .then(() => { - this.logger.debug(`Mail sent: ${to}`); - }) - .catch(async (error) => { - this.logger.debug(`Send mail failed: ${error.message}`); - transporter = this.getNextTransporter(transporterName); - this.logger.debug(`Retry send mail with transporter: ${transporter}`); - await this.sendConfirmMail( - { - to: to, - mailData, - }, - { - transporter, - retryCount: retryCount + 1, - }, - ); }); - } - - async sendForgotPassword( - mailData: MailData<{ url: string; tokenExpires: number; returnUrl?: string }>, - retryData: { - retryCount?: number; - transporter?: string; - } = { retryCount: 0, transporter: SENDGRID_TRANSPORT }, - ) { - const { to, data } = mailData; + this.logger.debug(`Mail sent: ${mailData.to}`); + } catch (error) { + this.logger.error(`Send mail failed: ${(error as Error)?.message}`); + const nextTransporter = this.getNextTransporter(transporterName); + this.logger.debug(`Retry send mail with transporter: ${nextTransporter}`); + await this.sendMailWithRetry(mailData, retryCount + 1, nextTransporter); + } - let { transporter = SENDGRID_TRANSPORT } = retryData; - const { retryCount = 0 } = retryData; + return { message: `Email sent to ${mailData.to}` }; + } - if (retryCount > this.MAX_RETRIES) { - this.logger.debug(`Send mail failed: too many retries`); - return { - message: 'Failed to send email', - }; - } + async sendConfirmMail(data: { to: string; mailData: { url: string }; isResend?: boolean }) { + const { to, isResend = false, mailData } = data; + const template = 'confirm-email'; + + const titleKey = isResend + ? 'mail-context.RESEND_CONFIRM_EMAIL.title' + : 'mail-context.CONFIRM_EMAIL.title'; + const text1Key = isResend + ? 'mail-context.RESEND_CONFIRM_EMAIL.text1' + : 'mail-context.CONFIRM_EMAIL.text1'; + const text2Key = isResend + ? 'mail-context.RESEND_CONFIRM_EMAIL.text2' + : 'mail-context.CONFIRM_EMAIL.text2'; + const text3Key = isResend + ? 'mail-context.RESEND_CONFIRM_EMAIL.text3' + : 'mail-context.CONFIRM_EMAIL.text3'; + const btn1Key = isResend + ? 'mail-context.RESEND_CONFIRM_EMAIL.btn1' + : 'mail-context.CONFIRM_EMAIL.btn1'; + + const [title, text1, text2, text3, btn1]: MaybeType[] = await Promise.all([ + this.i18n.t(titleKey), + this.i18n.t(text1Key), + this.i18n.t(text2Key), + this.i18n.t(text3Key), + this.i18n.t(btn1Key), + ]); + + await this.sendMailWithRetry({ + to, + subject: title, + template, + context: { title, url: mailData.url.toString(), text1, text2, text3, btn1 }, + }); + } - let resetPasswordTitle: MaybeType; - let text1: MaybeType; - let text2: MaybeType; - let text3: MaybeType; - let btn1: MaybeType; + async sendForgotPassword(mailData: { + to: string; + data: { url: string; tokenExpires: number; returnUrl?: string }; + }) { + const { to, data } = mailData; + const template = 'reset-password'; - if (this.i18n) { - [resetPasswordTitle, text1, text2, text3, btn1] = await Promise.all([ + const [resetPasswordTitle, text1, text2, text3, btn1]: MaybeType[] = + await Promise.all([ this.i18n.t('mail-context.RESET_PASSWORD.title'), this.i18n.t('mail-context.RESET_PASSWORD.text1'), this.i18n.t('mail-context.RESET_PASSWORD.text2'), this.i18n.t('mail-context.RESET_PASSWORD.text3'), this.i18n.t('mail-context.RESET_PASSWORD.btn1'), ]); - } const url = new URL(data.url); url.searchParams.set('expires', data.tokenExpires.toString()); - const transporterName = this.resolveTransporter(transporter); - this.logger.debug(`Sending forgot mail to ${to} with transporter: ${transporterName}`); - return await this.mailerService - .sendMail({ - transporterName, - attachments: this.BASE_ATTACHMENT, - to: to, - subject: resetPasswordTitle, - template: 'reset-password', - context: { - title: resetPasswordTitle, - url: url.toString(), - text1, - text2, - text3, - btn1, - }, - }) - .then(() => { - this.logger.debug(`Mail sent: ${to}`); - }) - .catch(async (error) => { - this.logger.error(`Send mail failed: ${error.message}`); - transporter = this.getNextTransporter(transporterName); - this.logger.debug(`Retry send mail with transporter: ${transporter}`); - await this.sendForgotPassword(mailData, { - transporter: transporter, - retryCount: retryCount + 1, - }); - }); + await this.sendMailWithRetry({ + to, + subject: resetPasswordTitle, + template, + context: { title: resetPasswordTitle, url: url.toString(), text1, text2, text3, btn1 }, + }); } - async sendLogin( - data: { - to: string; - mailData: { - url: string; - }; - }, - retryData: { - retryCount?: number; - transporter?: string; - } = { retryCount: 0, transporter: SENDGRID_TRANSPORT }, - ): Promise { + async sendLogin(data: { to: string; mailData: { url: string } }) { const { to, mailData } = data; + const template = 'login'; + + const [emailConfirmTitle, text1, text2, text3, btn1]: MaybeType[] = + await Promise.all([ + this.i18n.t('mail-context.LOGIN_EMAIL.title'), + this.i18n.t('mail-context.LOGIN_EMAIL.text1'), + this.i18n.t('mail-context.LOGIN_EMAIL.text2'), + this.i18n.t('mail-context.LOGIN_EMAIL.text3'), + this.i18n.t('mail-context.LOGIN_EMAIL.btn1'), + ]); - let { transporter = SENDGRID_TRANSPORT } = retryData; - const { retryCount = 0 } = retryData; - - let emailConfirmTitle: MaybeType; - let text1: MaybeType; - let text2: MaybeType; - let text3: MaybeType; - let btn1: MaybeType; - - if (retryCount > this.MAX_RETRIES) { - this.logger.debug(`Send mail failed: too many retries`); - return { - message: 'Failed to send email', - }; - } - - if (this.i18n) { - emailConfirmTitle = this.i18n.t('mail-context.LOGIN_EMAIL.title'); - text1 = this.i18n.t('mail-context.LOGIN_EMAIL.text1'); - text2 = this.i18n.t('mail-context.LOGIN_EMAIL.text2'); - text3 = this.i18n.t('mail-context.LOGIN_EMAIL.text3'); - btn1 = this.i18n.t('mail-context.LOGIN_EMAIL.btn1'); - } + await this.sendMailWithRetry({ + to, + subject: emailConfirmTitle, + template, + context: { title: emailConfirmTitle, url: mailData.url, text1, text2, text3, btn1 }, + }); + } - const transporterName = this.resolveTransporter(transporter); - this.logger.debug(`Sending login mail to ${to} with transporter: ${transporterName}`); - return this.mailerService - .sendMail({ - transporterName, - to: to, - subject: emailConfirmTitle, - template: 'login', - attachments: this.BASE_ATTACHMENT, - context: { - title: emailConfirmTitle, - url: mailData.url, - text1, - text2, - text3, - btn1, - }, - }) - .then(() => { - this.logger.debug(`Mail sent: ${to}`); - }) - .catch(async (error) => { - this.logger.debug(`Send mail failed: ${error.message}`); - transporter = this.getNextTransporter(transporterName); - this.logger.debug(`Retry send mail with transporter: ${transporter}`); - await this.sendLogin( - { - to: to, - mailData, - }, - { - transporter, - retryCount: retryCount + 1, - }, - ); - }); + async sendStopTask(data: { to: string; mailData: { url: string } }) { + const { to, mailData } = data; + const template = 'stop-task-notify'; + + const [stopTaskTitle, text1, text2, btn1]: MaybeType[] = await Promise.all([ + this.i18n.t('mail-context.STOP_TASK.title'), + this.i18n.t('mail-context.STOP_TASK.text1'), + this.i18n.t('mail-context.STOP_TASK.text2'), + this.i18n.t('mail-context.STOP_TASK.btn1'), + ]); + + await this.sendMailWithRetry({ + to, + subject: stopTaskTitle, + template, + context: { title: stopTaskTitle, url: mailData.url, text1, text2, btn1 }, + }); } } diff --git a/apps/be/common/src/mail/templates/stop-task-notify.hbs b/apps/be/common/src/mail/templates/stop-task-notify.hbs new file mode 100644 index 0000000..7dc404f --- /dev/null +++ b/apps/be/common/src/mail/templates/stop-task-notify.hbs @@ -0,0 +1,42 @@ + + + + + + + + {{title}} + + + + + + + +
+ + + + + + + +
+

{{title}}

+

{{text1}}

+

{{text2}}

+ + {{btn1}} + +
+
+

© 2024, made with ❤️ by + lehuygiang28 +

+
+
+ + + \ No newline at end of file diff --git a/apps/be/src/app/config/app-config.ts b/apps/be/src/app/config/app-config.ts index 4d58401..57e277a 100644 --- a/apps/be/src/app/config/app-config.ts +++ b/apps/be/src/app/config/app-config.ts @@ -26,6 +26,10 @@ class EnvironmentVariablesValidator { @IsString() GLOBAL_PREFIX: string; + @IsOptional() + @IsString() + FE_DOMAIN: string; + @IsOptional() @IsNumber() @Type(() => Number) @@ -66,6 +70,9 @@ export default registerAs('app', () => { globalPrefix: process.env?.GLOBAL_PREFIX ? removeLeadingAndTrailingSlashes(process.env.GLOBAL_PREFIX) : 'api', + feDomain: process.env?.FE_DOMAIN + ? removeLeadingAndTrailingSlashes(process.env.FE_DOMAIN) + : 'https://tasktr.vercel.app', workerName: process.env?.WORKER_NAME || 'default', eventsMaxLen: process.env?.BULLMQ_EVENTS_MAXLEN ? parseInt(process.env?.BULLMQ_EVENTS_MAXLEN, 10) diff --git a/apps/be/src/app/config/app-config.type.ts b/apps/be/src/app/config/app-config.type.ts index 97d2b8c..e88475c 100644 --- a/apps/be/src/app/config/app-config.type.ts +++ b/apps/be/src/app/config/app-config.type.ts @@ -3,6 +3,7 @@ export type AppConfig = { workerMode: boolean; workerName: string; globalPrefix: string; + feDomain: string; eventsMaxLen: number; port: number; fallbackLanguage: string; diff --git a/apps/be/src/app/tasks/processors/task.processor.ts b/apps/be/src/app/tasks/processors/task.processor.ts index 83731d7..58a6f97 100644 --- a/apps/be/src/app/tasks/processors/task.processor.ts +++ b/apps/be/src/app/tasks/processors/task.processor.ts @@ -1,6 +1,7 @@ import { Injectable, OnModuleInit } from '@nestjs/common'; import { PinoLogger } from 'nestjs-pino'; import { HttpService } from '@nestjs/axios'; +import { ConfigService } from '@nestjs/config'; import { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios'; import { Processor, WorkerHost } from '@nestjs/bullmq'; import { Job, Queue } from 'bullmq'; @@ -12,6 +13,9 @@ import { CreateTaskLogDto, TaskLogJobName } from '~be/app/task-logs'; import { defaultHeaders } from '~be/common/axios'; import { normalizeHeaders } from '~be/common/utils'; import { RedisService } from '~be/common/redis/services'; +import { MailService } from '~be/common/mail'; +import { UsersService } from '~be/app/users'; +import { AllConfig } from '~be/app/config'; import { TASK_FAIL_STREAK_PREFIX } from '../tasks.constant'; import { TasksService } from '../services'; @@ -32,6 +36,9 @@ export class TaskProcessor extends WorkerHost implements OnModuleInit { private readonly taskLogQueue: Queue, private readonly redisService: RedisService, private readonly taskService: TasksService, + private readonly configService: ConfigService, + private readonly mailService: MailService, + private readonly usersService: UsersService, ) { super(); this.logger.setContext(TaskProcessor.name); @@ -115,7 +122,15 @@ export class TaskProcessor extends WorkerHost implements OnModuleInit { `Task ${job.data._id.toString()} has been disabled due to too many failures: ${newFailedStreak[job.data._id.toString()]} / ${maxFailStreak}`, ); - const promises: unknown[] = [this.taskService.disableTask({ task: job.data })]; + const promises: unknown[] = [ + this.taskService.disableTask({ task: job.data }), + this.mailService.sendStopTask({ + to: (await this.usersService.findById(job.data.userId.toString())).email, + mailData: { + url: `${this.configService.getOrThrow('app.feDomain', { infer: true })}/tasks/show/${job.data._id.toString()}`, + }, + }), + ]; delete newFailedStreak[job.data._id.toString()]; diff --git a/apps/be/src/app/tasks/tasks.module.ts b/apps/be/src/app/tasks/tasks.module.ts index fe61f2e..aba96f0 100644 --- a/apps/be/src/app/tasks/tasks.module.ts +++ b/apps/be/src/app/tasks/tasks.module.ts @@ -11,6 +11,8 @@ import { } from '~be/common/bullmq/bullmq.constant'; import { axiosConfig } from '~be/common/axios'; import { RedisModule } from '~be/common/redis'; +import { MailModule } from '~be/common/mail'; +import { UsersModule } from '~be/app/users'; import { Task, TaskSchema } from './schemas'; import { TasksController } from './tasks.controller'; @@ -41,6 +43,8 @@ if (!(process.env['CLEAR_LOG_CONCURRENCY'] && Number(process.env['CLEAR_LOG_CONC HttpModule.register(axiosConfig), MongooseModule.forFeature([{ name: Task.name, schema: TaskSchema }]), RedisModule, + MailModule, + UsersModule, TaskLogsModule, BullModule.registerQueue({ name: BULLMQ_TASK_QUEUE,