Deploy to staging #53
			
				
			
		
		
		
	| 
						 | 
				
			
			@ -1,178 +0,0 @@
 | 
			
		|||
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN">
 | 
			
		||||
<html>
 | 
			
		||||
<head>
 | 
			
		||||
<meta http-equiv=3D"Content-Type" content=3D"text/html; charset=3Dutf-8">
 | 
			
		||||
<title>Callcentric Email</title>
 | 
			
		||||
<style type=3D"text/css" media=3D"all">
 | 
			
		||||
body {
 | 
			
		||||
        font-family: arial,helvetica,sans-serif;
 | 
			
		||||
        font-size: 14px;
 | 
			
		||||
        line-height: 22px;
 | 
			
		||||
        color: #444;
 | 
			
		||||
        margin: 0;
 | 
			
		||||
        padding: 20px 0;
 | 
			
		||||
        background-color: #e5e5e5;
 | 
			
		||||
        outline: 0;
 | 
			
		||||
        vertical-align: top;
 | 
			
		||||
}
 | 
			
		||||
p {
 | 
			
		||||
        margin-bottom: 1.5em;
 | 
			
		||||
}
 | 
			
		||||
img {
 | 
			
		||||
        display: block;
 | 
			
		||||
}
 | 
			
		||||
a:link,
 | 
			
		||||
a:visited {
 | 
			
		||||
        color: #006699;
 | 
			
		||||
        text-decoration: underline;
 | 
			
		||||
        margin: 0px;
 | 
			
		||||
        padding: 0px;
 | 
			
		||||
}
 | 
			
		||||
a:hover {=20
 | 
			
		||||
        text-decoration: none;
 | 
			
		||||
}
 | 
			
		||||
li {
 | 
			
		||||
        margin-bottom: 1em;
 | 
			
		||||
        line-height: 22px;
 | 
			
		||||
}=09
 | 
			
		||||
=2Efooter a {
 | 
			
		||||
        color: #aaaaaa;
 | 
			
		||||
        text-decoration: underline;
 | 
			
		||||
}
 | 
			
		||||
=2Efooter a:hover {
 | 
			
		||||
        text-decoration: none;
 | 
			
		||||
}
 | 
			
		||||
=2Eframe {
 | 
			
		||||
        width: 580px;
 | 
			
		||||
}
 | 
			
		||||
#btn {
 | 
			
		||||
  background: #3498db;
 | 
			
		||||
  background-image: -webkit-linear-gradient(top, #3498db, #2980b9);
 | 
			
		||||
  background-image: -moz-linear-gradient(top, #3498db, #2980b9);
 | 
			
		||||
  background-image: -ms-linear-gradient(top, #3498db, #2980b9);
 | 
			
		||||
  background-image: -o-linear-gradient(top, #3498db, #2980b9);
 | 
			
		||||
  background-image: linear-gradient(to bottom, #3498db, #2980b9);
 | 
			
		||||
  -webkit-border-radius: 28;
 | 
			
		||||
  -moz-border-radius: 28;
 | 
			
		||||
  border-radius: 28px;
 | 
			
		||||
  font-family: Arial;
 | 
			
		||||
  color: #ffffff;
 | 
			
		||||
  font-size: 20px;
 | 
			
		||||
  padding: 10px 20px 10px 20px;
 | 
			
		||||
  text-decoration: none;
 | 
			
		||||
  display: inline-block;
 | 
			
		||||
}
 | 
			
		||||
#btn:hover {
 | 
			
		||||
  background: #3cb0fd;
 | 
			
		||||
  background-image: -webkit-linear-gradient(top, #3cb0fd, #3498db);
 | 
			
		||||
  background-image: -moz-linear-gradient(top, #3cb0fd, #3498db);
 | 
			
		||||
  background-image: -ms-linear-gradient(top, #3cb0fd, #3498db);
 | 
			
		||||
  background-image: -o-linear-gradient(top, #3cb0fd, #3498db);
 | 
			
		||||
  background-image: linear-gradient(to bottom, #3cb0fd, #3498db);
 | 
			
		||||
  text-decoration: none;
 | 
			
		||||
}
 | 
			
		||||
=2Esms-box { font-family: arial,helvetica,sans-serif; font-size: 14px; padd=
 | 
			
		||||
ing:12px; border:1px solid #e0e0e0; background-color: #e0e0e0; -webkit-bord=
 | 
			
		||||
er-radius: 12px; -moz-border-radius: 12px; border-radius: 12px; word-wrap:b=
 | 
			
		||||
reak-word; display:inline-block; }
 | 
			
		||||
p { font-family: arial,helvetica,sans-serif; font-size: 14px; margin-bottom=
 | 
			
		||||
: 1.5em; line-height: 22px; }
 | 
			
		||||
 | 
			
		||||
@media only screen and (max-device-width: 480px) {
 | 
			
		||||
        .frame {
 | 
			
		||||
                width: 442px !important;
 | 
			
		||||
         }
 | 
			
		||||
 | 
			
		||||
        p {
 | 
			
		||||
                font-size: 22px !important;
 | 
			
		||||
                line-height: 28px !important;
 | 
			
		||||
        }
 | 
			
		||||
        ul li {
 | 
			
		||||
                font-size: 22px !important;
 | 
			
		||||
                line-height: 28px !important;
 | 
			
		||||
        }
 | 
			
		||||
        .section_header{
 | 
			
		||||
                font-size: 28px !important;
 | 
			
		||||
                line-height: 30px !important;
 | 
			
		||||
        }
 | 
			
		||||
        #btn {
 | 
			
		||||
                font-size: 28px !important;
 | 
			
		||||
                padding: 14px 24px 14px 24px !important;
 | 
			
		||||
        }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
</style>
 | 
			
		||||
</head>
 | 
			
		||||
<body style=3D"font-family: Arial, sans-serif; font-size: 14px; margin-bott=
 | 
			
		||||
om: 1.5em; line-height: 22px; color: black; width: 100% !important; margin:=
 | 
			
		||||
 0; padding: 0px; background-color: #e5e5e5; outline: 0; vertical-align: to=
 | 
			
		||||
p;" bgcolor=3D"#e5e5e5">
 | 
			
		||||
<table width=3D"100%" cellpadding=3D"0" cellspacing=3D"0" border=3D"0"><tr>=
 | 
			
		||||
<td style=3D"padding: 0px 0px;" align=3D"center">
 | 
			
		||||
<table class=3D"head" width=3D"100%" bgcolor=3D"#c9c9c9" style=3D"backgroun=
 | 
			
		||||
d-color:#c9c9c9;">
 | 
			
		||||
        <tr><td align=3D"center" style=3D"color:#f3f3f3; padding:5px; font-size:12=
 | 
			
		||||
px;">Please do not respond to this automated message, you will not receive =
 | 
			
		||||
a reply</td></tr>
 | 
			
		||||
</table>
 | 
			
		||||
</td></tr><tr><td style=3D"padding: 0px 10px;" align=3D"center">
 | 
			
		||||
<table class=3D"frame" width=3D"580" cellpadding=3D"0" cellspacing=3D"0" bo=
 | 
			
		||||
rder=3D"0" align=3D"center"><tbody>
 | 
			
		||||
        <tr><td>
 | 
			
		||||
                <table width=3D"100%" cellpadding=3D"0" cellspacing=3D"0" border=3D"0">
 | 
			
		||||
                        <tr><td style=3D"padding: 30px 0px 10px 0px; color: #444; text-align: le=
 | 
			
		||||
ft; vertical-align: middle;" align=3D"left"><a href=3D"https://www.callcent=
 | 
			
		||||
ric.com"><img src=3D"https://www.callcentric.com/img/callcentric_logo_250_t=
 | 
			
		||||
rans.png" width=3D"125" height=3D"33" alt=3D"Callcentric" border=3D"0"></a>=
 | 
			
		||||
</td></tr>
 | 
			
		||||
                </table>
 | 
			
		||||
        </td></tr>
 | 
			
		||||
        <tr><td style=3D"background-color: #ffffff; padding: 0px 0px 50px; border-=
 | 
			
		||||
radius: 5px 5px 0px 0px;">
 | 
			
		||||
                <table style=3D"padding-top: 40px;" width=3D"100%" cellpadding=3D"0" cell=
 | 
			
		||||
spacing=3D"0" border=3D"0"><tbody>
 | 
			
		||||
                        <tr><td class=3D"flex-column" style=3D"width: 40px;"></td>
 | 
			
		||||
                        <td style=3D"text-align: left;" align=3D"left">
 | 
			
		||||
<p>Dear Morgan Vu,</p><p>You have received an inbound text message from 223=
 | 
			
		||||
95 on your DID 17146893350:</p><div class=3D"sms-box">Your LANGTONS verific=
 | 
			
		||||
ation code is: 012152. This code will expire in 10 minutes.</div>
 | 
			
		||||
 | 
			
		||||
                        </td><td class=3D"flex-column" style=3D"width: 40px;"></td></tr>
 | 
			
		||||
                </tbody></table>
 | 
			
		||||
        </td></tr>
 | 
			
		||||
        <tr><td align=3D"left" class=3D"footer" style=3D"background-color: #f2f2f2=
 | 
			
		||||
; color: #999999; padding: 0px 0px 40px; border-radius: 0px 0px 5px 5px;">
 | 
			
		||||
                <table border=3D"0" cellpadding=3D"0" cellspacing=3D"0" style=3D"padding-=
 | 
			
		||||
top: 40px;" width=3D"100%"><tbody>
 | 
			
		||||
                        <tr><td style=3D"width: 40px;"></td>
 | 
			
		||||
                                <td align=3D"left" style=3D"text-align: left; font-family:Arial, Helvet=
 | 
			
		||||
ica, sans-serif; font-size: 15px; line-height: 22px; color: #999999;">
 | 
			
		||||
 | 
			
		||||
Follow us on Twitter at:<br>
 | 
			
		||||
<a href=3D"https://twitter.com/Callcentric">https://twitter.com/Callcentric=
 | 
			
		||||
</a>
 | 
			
		||||
<br><br>
 | 
			
		||||
 | 
			
		||||
If you have any questions/concerns, please contact our Customer Service Tea=
 | 
			
		||||
m via the following page:<br>
 | 
			
		||||
<a href=3D"https://www.callcentric.com/CustomerService">https://www.callcen=
 | 
			
		||||
tric.com/CustomerService</a><br><br>
 | 
			
		||||
 | 
			
		||||
For instructions on how to unsubscribe from our Newsletters/Service Announc=
 | 
			
		||||
ements please visit:<br>
 | 
			
		||||
 | 
			
		||||
<a href=3D"https://www.callcentric.com/faq/11/306">https://www.callcentric=
 | 
			
		||||
=2Ecom/faq/11/306</a><br><br>
 | 
			
		||||
 | 
			
		||||
<a href=3D"https://www.callcentric.com">www.callcentric.com</a><br><br>
 | 
			
		||||
 | 
			
		||||
                                </td><td style=3D"width: 40px;"></td>
 | 
			
		||||
                        </tr>
 | 
			
		||||
                </tbody></table>
 | 
			
		||||
        </td></tr>
 | 
			
		||||
        <tr><td style=3D"padding: 20px 0; font-size: 15px; line-height: 25px; colo=
 | 
			
		||||
r: #999999; text-align: center;" align=3D"center"></td></tr>
 | 
			
		||||
</tbody></table></td></tr></table>
 | 
			
		||||
 | 
			
		||||
</body>
 | 
			
		||||
</html>
 | 
			
		||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| 
						 | 
				
			
			@ -25,6 +25,7 @@
 | 
			
		|||
    "migration:revert": "npm run typeorm -- -d ./src/config/typeorm.ts migration:revert"
 | 
			
		||||
  },
 | 
			
		||||
  "dependencies": {
 | 
			
		||||
    "@nestjs-modules/mailer": "^2.0.2",
 | 
			
		||||
    "@nestjs/common": "^10.0.0",
 | 
			
		||||
    "@nestjs/config": "^4.0.1",
 | 
			
		||||
    "@nestjs/core": "^10.0.0",
 | 
			
		||||
| 
						 | 
				
			
			@ -51,6 +52,7 @@
 | 
			
		|||
    "multer": "^1.4.5-lts.1",
 | 
			
		||||
    "mysql2": "^3.13.0",
 | 
			
		||||
    "nestjs-paginate": "^11.1.0",
 | 
			
		||||
    "nodemailer": "^7.0.3",
 | 
			
		||||
    "reflect-metadata": "^0.2.0",
 | 
			
		||||
    "rxjs": "^7.8.1",
 | 
			
		||||
    "sharp": "^0.33.5",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -16,6 +16,7 @@ import { ClientAuthenticationMiddleware } from './modules/auth/middlewares/clien
 | 
			
		|||
import { NotificationModule } from './modules/notification/notification.module';
 | 
			
		||||
import { ScrapsModule } from './modules/scraps/scraps.module';
 | 
			
		||||
import { ExtensionsModule } from './modules/extensions/extensions.module';
 | 
			
		||||
import { MailsModule } from './modules/mails/mails.module';
 | 
			
		||||
 | 
			
		||||
@Module({
 | 
			
		||||
  imports: [
 | 
			
		||||
| 
						 | 
				
			
			@ -28,6 +29,7 @@ import { ExtensionsModule } from './modules/extensions/extensions.module';
 | 
			
		|||
    NotificationModule,
 | 
			
		||||
    ScrapsModule,
 | 
			
		||||
    ExtensionsModule,
 | 
			
		||||
    MailsModule,
 | 
			
		||||
  ],
 | 
			
		||||
  controllers: [],
 | 
			
		||||
  providers: [],
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -39,7 +39,7 @@ import { Config } from './entities/configs.entity';
 | 
			
		|||
      OutBidLog,
 | 
			
		||||
      WebBid,
 | 
			
		||||
      SendMessageHistory,
 | 
			
		||||
      Config
 | 
			
		||||
      Config,
 | 
			
		||||
    ]),
 | 
			
		||||
    // AuthModule,
 | 
			
		||||
    AdminsModule,
 | 
			
		||||
| 
						 | 
				
			
			@ -69,8 +69,13 @@ import { Config } from './entities/configs.entity';
 | 
			
		|||
    ImapService,
 | 
			
		||||
    DashboardService,
 | 
			
		||||
    TasksService,
 | 
			
		||||
    ConfigsService
 | 
			
		||||
    ConfigsService,
 | 
			
		||||
  ],
 | 
			
		||||
  exports: [
 | 
			
		||||
    BotTelegramApi,
 | 
			
		||||
    SendMessageHistoriesService,
 | 
			
		||||
    BidsService,
 | 
			
		||||
    ConfigsService,
 | 
			
		||||
  ],
 | 
			
		||||
  exports: [BotTelegramApi, SendMessageHistoriesService, BidsService],
 | 
			
		||||
})
 | 
			
		||||
export class BidsModule {}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -8,22 +8,25 @@ import { Config } from '../entities/configs.entity';
 | 
			
		|||
export class ConfigsService {
 | 
			
		||||
  public static CONFIG_KEYS = {
 | 
			
		||||
    REFRESH_TOOL_TIME: 'REFRESH_TOOL_TIME',
 | 
			
		||||
    MAIL_SCRAP_REPORT: 'MAIL_SCRAP_REPORT',
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  constructor(
 | 
			
		||||
    @InjectRepository(Config)
 | 
			
		||||
    readonly configRepo: Repository<Config>,
 | 
			
		||||
    private eventEmitter: EventEmitter2,
 | 
			
		||||
  ) {}
 | 
			
		||||
 | 
			
		||||
  async getConfig(key_name: keyof typeof ConfigsService.CONFIG_KEYS) {
 | 
			
		||||
    return await this.configRepo.findOne({ where: { key_name } }) || null;
 | 
			
		||||
    return (await this.configRepo.findOne({ where: { key_name } })) || null;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async setConfig(key_name: keyof typeof ConfigsService.CONFIG_KEYS, value: string, type: 'string' | 'number') {
 | 
			
		||||
    return await this.configRepo.upsert(
 | 
			
		||||
      { key_name, value , type},
 | 
			
		||||
      ['key_name'] 
 | 
			
		||||
    );
 | 
			
		||||
  async setConfig(
 | 
			
		||||
    key_name: keyof typeof ConfigsService.CONFIG_KEYS,
 | 
			
		||||
    value: string,
 | 
			
		||||
    type: 'string' | 'number',
 | 
			
		||||
  ) {
 | 
			
		||||
    return await this.configRepo.upsert({ key_name, value, type }, [
 | 
			
		||||
      'key_name',
 | 
			
		||||
    ]);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,27 @@
 | 
			
		|||
import { MailerModule } from '@nestjs-modules/mailer';
 | 
			
		||||
import { Module } from '@nestjs/common';
 | 
			
		||||
import { MailsService } from './services/mails.service';
 | 
			
		||||
import { ConfigModule, ConfigService } from '@nestjs/config';
 | 
			
		||||
 | 
			
		||||
@Module({
 | 
			
		||||
  imports: [
 | 
			
		||||
    MailerModule.forRootAsync({
 | 
			
		||||
      imports: [ConfigModule],
 | 
			
		||||
      useFactory: async (config: ConfigService) => ({
 | 
			
		||||
        transport: {
 | 
			
		||||
          host: config.get<string>('MAIL_SERVER'),
 | 
			
		||||
          port: 465,
 | 
			
		||||
          secure: true, // true nếu port là 465
 | 
			
		||||
          auth: {
 | 
			
		||||
            user: config.get<string>('MAIL_USER'),
 | 
			
		||||
            pass: config.get<string>('MAIL_PASSWORD'),
 | 
			
		||||
          },
 | 
			
		||||
        },
 | 
			
		||||
      }),
 | 
			
		||||
      inject: [ConfigService],
 | 
			
		||||
    }),
 | 
			
		||||
  ],
 | 
			
		||||
  providers: [MailsService],
 | 
			
		||||
  exports: [MailsService],
 | 
			
		||||
})
 | 
			
		||||
export class MailsModule {}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,89 @@
 | 
			
		|||
import { Injectable } from '@nestjs/common';
 | 
			
		||||
import { MailerService } from '@nestjs-modules/mailer';
 | 
			
		||||
import { ScrapItem } from '@/modules/scraps/entities/scrap-item.entity';
 | 
			
		||||
import { extractDomain } from '@/ultils';
 | 
			
		||||
 | 
			
		||||
@Injectable()
 | 
			
		||||
export class MailsService {
 | 
			
		||||
  constructor(private readonly mailerService: MailerService) {}
 | 
			
		||||
 | 
			
		||||
  async sendPlainText(to: string, subject: string, content: string) {
 | 
			
		||||
    await this.mailerService.sendMail({
 | 
			
		||||
      to,
 | 
			
		||||
      subject,
 | 
			
		||||
      text: content,
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async sendPlainHtml(to: string, subject: string, html: string) {
 | 
			
		||||
    const emails = to
 | 
			
		||||
      .split(',')
 | 
			
		||||
      .map((e) => e.trim())
 | 
			
		||||
      .filter(Boolean);
 | 
			
		||||
 | 
			
		||||
    const mainRecipient = emails[0];
 | 
			
		||||
    const ccRecipients = emails.slice(1); // Những email còn lại
 | 
			
		||||
 | 
			
		||||
    await this.mailerService.sendMail({
 | 
			
		||||
      to: mainRecipient,
 | 
			
		||||
      cc: ccRecipients.length > 0 ? ccRecipients : undefined,
 | 
			
		||||
      subject,
 | 
			
		||||
      html,
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  generateProductTableHTML(products: ScrapItem[]): string {
 | 
			
		||||
    const rows = products
 | 
			
		||||
      .map(
 | 
			
		||||
        (p) => `
 | 
			
		||||
        <tr>
 | 
			
		||||
          <td><img src="${p.image_url}" alt="Product Image" style="height: 40px; object-fit: contain; border-radius: 4px;" /></td>
 | 
			
		||||
          <td>${p.name}</td>
 | 
			
		||||
          <td style="font-weight: bold; color: #e03131;">$${p.current_price}</td>
 | 
			
		||||
          <td><a href="${p.url}" target="_blank" style="color: #007bff;">View</a></td>
 | 
			
		||||
          <td>${extractDomain(p.scrap_config.web_bid.origin_url)}</td>
 | 
			
		||||
        </tr>
 | 
			
		||||
      `,
 | 
			
		||||
      )
 | 
			
		||||
      .join('');
 | 
			
		||||
 | 
			
		||||
    return `
 | 
			
		||||
  <!DOCTYPE html>
 | 
			
		||||
  <html lang="en">
 | 
			
		||||
  <head>
 | 
			
		||||
    <meta charset="UTF-8" />
 | 
			
		||||
    <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
 | 
			
		||||
    <title>Products</title>
 | 
			
		||||
  </head>
 | 
			
		||||
  <body style="font-family: sans-serif; background: #f8f9fa; padding: 20px;">
 | 
			
		||||
    <h2 style="text-align: center; color: #333;">Product Listing</h2>
 | 
			
		||||
    <div style="overflow-x: auto;">
 | 
			
		||||
      <table style="width: 100%; min-width: 500px; border-collapse: collapse; background: #fff; border-radius: 6px; box-shadow: 0 2px 6px rgba(0,0,0,0.05);">
 | 
			
		||||
        <thead style="background: #f1f3f5;">
 | 
			
		||||
          <tr>
 | 
			
		||||
            <th style="padding: 8px; text-align: left; border-bottom: 1px solid #ccc;">Image</th>
 | 
			
		||||
            <th style="padding: 8px; text-align: left; border-bottom: 1px solid #ccc;">Name</th>
 | 
			
		||||
            <th style="padding: 8px; text-align: left; border-bottom: 1px solid #ccc;">Price</th>
 | 
			
		||||
            <th style="padding: 8px; text-align: left; border-bottom: 1px solid #ccc;">Link</th>
 | 
			
		||||
            <th style="padding: 8px; text-align: left; border-bottom: 1px solid #ccc;">Domain</th>
 | 
			
		||||
          </tr>
 | 
			
		||||
        </thead>
 | 
			
		||||
        <tbody>
 | 
			
		||||
          ${rows}
 | 
			
		||||
        </tbody>
 | 
			
		||||
      </table>
 | 
			
		||||
    </div>
 | 
			
		||||
  </body>
 | 
			
		||||
  </html>
 | 
			
		||||
  `;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async sendWithTemplate(to: string, subject: string, payload: any) {
 | 
			
		||||
    await this.mailerService.sendMail({
 | 
			
		||||
      to,
 | 
			
		||||
      subject,
 | 
			
		||||
      template: './welcome', // đường dẫn tương đối trong /templates
 | 
			
		||||
      context: payload, // dữ liệu cho template
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,16 +1,21 @@
 | 
			
		|||
import { MailsService } from '@/modules/mails/services/mails.service';
 | 
			
		||||
import { Body, Controller, Get, Param, Post, Put } from '@nestjs/common';
 | 
			
		||||
import { Between, IsNull, Not } from 'typeorm';
 | 
			
		||||
import { CreateScrapConfigDto } from '../dto/scrap-config/create-scrap-config';
 | 
			
		||||
import { ScrapConfigsService } from '../services/scrap-config.service';
 | 
			
		||||
import { UpdateScrapConfigDto } from '../dto/scrap-config/update-scrap-config';
 | 
			
		||||
import { ScrapConfig } from '../entities/scrap-config.entity';
 | 
			
		||||
import { IsNull, Not } from 'typeorm';
 | 
			
		||||
import { ScrapConfigsService } from '../services/scrap-config.service';
 | 
			
		||||
import { ScrapItemsService } from '../services/scrap-item-config.service';
 | 
			
		||||
import { ConfigsService } from '@/modules/bids/services/configs.service';
 | 
			
		||||
import * as moment from 'moment';
 | 
			
		||||
 | 
			
		||||
@Controller('admin/scrap-configs')
 | 
			
		||||
export class ScrapConfigsController {
 | 
			
		||||
  constructor(
 | 
			
		||||
    private readonly scrapConfigsService: ScrapConfigsService,
 | 
			
		||||
    private readonly scrapItemsService: ScrapItemsService,
 | 
			
		||||
    private readonly mailsService: MailsService,
 | 
			
		||||
    private readonly configsSerivce: ConfigsService,
 | 
			
		||||
  ) {}
 | 
			
		||||
 | 
			
		||||
  @Post()
 | 
			
		||||
| 
						 | 
				
			
			@ -28,6 +33,11 @@ export class ScrapConfigsController {
 | 
			
		|||
 | 
			
		||||
  @Get()
 | 
			
		||||
  async test() {
 | 
			
		||||
    const mails = (await this.configsSerivce.getConfig('MAIL_SCRAP_REPORT'))
 | 
			
		||||
      .value;
 | 
			
		||||
 | 
			
		||||
    if (!mails) return;
 | 
			
		||||
 | 
			
		||||
    const scrapConfigs = await this.scrapConfigsService.scrapConfigRepo.find({
 | 
			
		||||
      where: {
 | 
			
		||||
        search_url: Not(IsNull()),
 | 
			
		||||
| 
						 | 
				
			
			@ -37,24 +47,39 @@ export class ScrapConfigsController {
 | 
			
		|||
        web_bid: true,
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    const models = this.scrapConfigsService.scrapModels(scrapConfigs);
 | 
			
		||||
 | 
			
		||||
    await Promise.allSettled(
 | 
			
		||||
      models.map(async (item) => {
 | 
			
		||||
        await item.action();
 | 
			
		||||
 | 
			
		||||
        Object.keys(item.results).forEach(async (key) => {
 | 
			
		||||
          const data = item.results[key];
 | 
			
		||||
 | 
			
		||||
          await this.scrapItemsService.scrapItemRepo.upsert(data, [
 | 
			
		||||
            'model',
 | 
			
		||||
            'scrap_config',
 | 
			
		||||
          ]);
 | 
			
		||||
        });
 | 
			
		||||
        for (const key of Object.keys(item.results)) {
 | 
			
		||||
          const dataArray = item.results[key];
 | 
			
		||||
          const result =
 | 
			
		||||
            await this.scrapItemsService.upsertScrapItems(dataArray);
 | 
			
		||||
          console.log(result);
 | 
			
		||||
        }
 | 
			
		||||
      }),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    const startOfDay = new Date();
 | 
			
		||||
    startOfDay.setHours(0, 0, 0, 0);
 | 
			
		||||
 | 
			
		||||
    const endOfDay = new Date();
 | 
			
		||||
    endOfDay.setHours(23, 59, 59, 999);
 | 
			
		||||
 | 
			
		||||
    const data = await this.scrapItemsService.scrapItemRepo.find({
 | 
			
		||||
      where: {
 | 
			
		||||
        updated_at: Between(startOfDay, endOfDay),
 | 
			
		||||
      },
 | 
			
		||||
      relations: { scrap_config: { web_bid: true } },
 | 
			
		||||
      order: { updated_at: 'DESC' },
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    await this.mailsService.sendPlainHtml(
 | 
			
		||||
      mails,
 | 
			
		||||
      `Auction Items Matching Your Keywords – Daily Update ${moment(new Date()).format('YYYY-MM-DD HH:mm')}`,
 | 
			
		||||
      this.mailsService.generateProductTableHTML(data),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    return { a: 'abc' };
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,6 +1,7 @@
 | 
			
		|||
import {
 | 
			
		||||
  Column,
 | 
			
		||||
  Entity,
 | 
			
		||||
  JoinColumn,
 | 
			
		||||
  ManyToOne,
 | 
			
		||||
  PrimaryGeneratedColumn,
 | 
			
		||||
  Unique,
 | 
			
		||||
| 
						 | 
				
			
			@ -9,7 +10,7 @@ import { ScrapConfig } from './scrap-config.entity';
 | 
			
		|||
import { Timestamp } from './timestamp';
 | 
			
		||||
 | 
			
		||||
@Entity('scrap-items')
 | 
			
		||||
@Unique(['model', 'scrap_config'])
 | 
			
		||||
@Unique(['model', 'scrap_config_id'])
 | 
			
		||||
export class ScrapItem extends Timestamp {
 | 
			
		||||
  @PrimaryGeneratedColumn('increment')
 | 
			
		||||
  id: number;
 | 
			
		||||
| 
						 | 
				
			
			@ -29,11 +30,15 @@ export class ScrapItem extends Timestamp {
 | 
			
		|||
  @Column({ nullable: true, default: null })
 | 
			
		||||
  keyword: string;
 | 
			
		||||
 | 
			
		||||
  @Column({ nullable: true, default: null })
 | 
			
		||||
  @Column({ nullable: true, default: null, type: 'float' })
 | 
			
		||||
  current_price: number;
 | 
			
		||||
 | 
			
		||||
  @Column()
 | 
			
		||||
  scrap_config_id: number;
 | 
			
		||||
 | 
			
		||||
  @ManyToOne(() => ScrapConfig, (web) => web.scrap_items, {
 | 
			
		||||
    onDelete: 'CASCADE',
 | 
			
		||||
  })
 | 
			
		||||
  @JoinColumn({ name: 'scrap_config_id' })
 | 
			
		||||
  scrap_config: ScrapConfig;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,6 +1,6 @@
 | 
			
		|||
import axios from 'axios';
 | 
			
		||||
import { ScrapInterface } from '../../scrap-interface';
 | 
			
		||||
import { ScrapModel } from '../../scrap-model';
 | 
			
		||||
import { ScrapInterface } from '../scrap-interface';
 | 
			
		||||
import { ScrapModel } from '../scrap-model';
 | 
			
		||||
import * as cheerio from 'cheerio';
 | 
			
		||||
import { Element } from 'domhandler';
 | 
			
		||||
import { ScrapItem } from '@/modules/scraps/entities/scrap-item.entity';
 | 
			
		||||
| 
						 | 
				
			
			@ -9,7 +9,6 @@ export class GraysScrapModel extends ScrapModel {
 | 
			
		|||
  action = async () => {
 | 
			
		||||
    const urls = this.extractUrls();
 | 
			
		||||
 | 
			
		||||
    console.log({ urls });
 | 
			
		||||
    const results = await Promise.allSettled(
 | 
			
		||||
      urls.map(async (item) => ({
 | 
			
		||||
        html: (await axios.get(item.url)).data,
 | 
			
		||||
| 
						 | 
				
			
			@ -37,6 +36,21 @@ export class GraysScrapModel extends ScrapModel {
 | 
			
		|||
    );
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  getPriceByEl = ($: cheerio.CheerioAPI, el: Element): number | null => {
 | 
			
		||||
    const selectors = [
 | 
			
		||||
      '.sc-ijDOKB.sc-bStcSt.ikmQUw.eEycyP', // Single product price
 | 
			
		||||
      '.sc-ijDOKB.ikmQUw', // Multiple product price
 | 
			
		||||
    ];
 | 
			
		||||
 | 
			
		||||
    for (const selector of selectors) {
 | 
			
		||||
      const text = $(el).find(selector).text();
 | 
			
		||||
      const price = extractNumber(text);
 | 
			
		||||
      if (price) return price;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return null;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  getItemsInHtml = async ({
 | 
			
		||||
    html,
 | 
			
		||||
    keyword,
 | 
			
		||||
| 
						 | 
				
			
			@ -59,10 +73,8 @@ export class GraysScrapModel extends ScrapModel {
 | 
			
		|||
        model: extractModelId(url),
 | 
			
		||||
        keyword,
 | 
			
		||||
        url,
 | 
			
		||||
        current_price: extractNumber(
 | 
			
		||||
          $(el).find('.sc-ijDOKB.sc-bStcSt.ikmQUw.eEycyP').text(),
 | 
			
		||||
        ),
 | 
			
		||||
        scrap_config: { id: this.scrap_config_id },
 | 
			
		||||
        current_price: this.getPriceByEl($, el),
 | 
			
		||||
        scrap_config_id: this.scrap_config_id,
 | 
			
		||||
      } as ScrapItem;
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,108 @@
 | 
			
		|||
import { ScrapItem } from '@/modules/scraps/entities/scrap-item.entity';
 | 
			
		||||
import { extractModelId, extractNumber } from '@/ultils';
 | 
			
		||||
import axios from 'axios';
 | 
			
		||||
import * as cheerio from 'cheerio';
 | 
			
		||||
import { Element } from 'domhandler';
 | 
			
		||||
import { ScrapModel } from '../scrap-model';
 | 
			
		||||
 | 
			
		||||
export class LangtonsScrapModel extends ScrapModel {
 | 
			
		||||
  action = async () => {
 | 
			
		||||
    const urls = this.extractUrls();
 | 
			
		||||
 | 
			
		||||
    console.log({ urls });
 | 
			
		||||
    const results = await Promise.allSettled(
 | 
			
		||||
      urls.map(async (item) => ({
 | 
			
		||||
        html: (
 | 
			
		||||
          await axios.get(item.url, {
 | 
			
		||||
            headers: {
 | 
			
		||||
              'User-Agent':
 | 
			
		||||
                'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36',
 | 
			
		||||
              Accept: 'text/html',
 | 
			
		||||
            },
 | 
			
		||||
            maxRedirects: 5, // default là 5, bạn có thể tăng lên
 | 
			
		||||
          })
 | 
			
		||||
        ).data,
 | 
			
		||||
        keyword: item.keyword,
 | 
			
		||||
      })),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    console.log(
 | 
			
		||||
      results.map((r) => ({
 | 
			
		||||
        status: r.status,
 | 
			
		||||
        reason:
 | 
			
		||||
          r.status === 'rejected'
 | 
			
		||||
            ? (r as PromiseRejectedResult).reason.message
 | 
			
		||||
            : undefined,
 | 
			
		||||
      })),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    const htmlsData = results
 | 
			
		||||
      .filter((res) => res.status === 'fulfilled')
 | 
			
		||||
      .map((res) => (res as PromiseFulfilledResult<any>).value);
 | 
			
		||||
 | 
			
		||||
    console.log({ htmlsData });
 | 
			
		||||
 | 
			
		||||
    await Promise.all(
 | 
			
		||||
      htmlsData.map(async (cur) => {
 | 
			
		||||
        try {
 | 
			
		||||
          const data = await this.getItemsInHtml(cur);
 | 
			
		||||
          const results = this.filterItemByKeyword(cur.keyword, data);
 | 
			
		||||
 | 
			
		||||
          this.results[cur.keyword] = results; // hoặc push như gợi ý trên
 | 
			
		||||
          return results;
 | 
			
		||||
        } catch (err) {
 | 
			
		||||
          console.error(`❌ Error with keyword ${cur.keyword}:`, err);
 | 
			
		||||
          return []; // fallback
 | 
			
		||||
        }
 | 
			
		||||
      }),
 | 
			
		||||
    );
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  getPriceByEl = ($: cheerio.CheerioAPI, el: Element): number | null => {
 | 
			
		||||
    const selectors = [
 | 
			
		||||
      '.sc-ijDOKB.sc-bStcSt.ikmQUw.eEycyP', // Single product price
 | 
			
		||||
      '.sc-ijDOKB.ikmQUw', // Multiple product price
 | 
			
		||||
    ];
 | 
			
		||||
 | 
			
		||||
    for (const selector of selectors) {
 | 
			
		||||
      const text = $(el).find(selector).text();
 | 
			
		||||
      const price = extractNumber(text);
 | 
			
		||||
      if (price) return price;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return null;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  getItemsInHtml = async ({
 | 
			
		||||
    html,
 | 
			
		||||
    keyword,
 | 
			
		||||
  }: {
 | 
			
		||||
    html: string;
 | 
			
		||||
    keyword: string;
 | 
			
		||||
  }) => {
 | 
			
		||||
    const $ = cheerio.load(html);
 | 
			
		||||
 | 
			
		||||
    const container = $('.row.product-grid.grid-view');
 | 
			
		||||
 | 
			
		||||
    const items = container.children('div').toArray();
 | 
			
		||||
 | 
			
		||||
    const results = items.map((el) => {
 | 
			
		||||
      const url = $(el).find('a.js-pdp-link.pdp-link-anchor').attr('href');
 | 
			
		||||
 | 
			
		||||
      return {
 | 
			
		||||
        name: $(el).find('.link.js-pdp-link').text().trim(),
 | 
			
		||||
        image_url: $(el).find('img.tile-image.loaded').attr('src'),
 | 
			
		||||
        model: extractModelId(url),
 | 
			
		||||
        keyword,
 | 
			
		||||
        url,
 | 
			
		||||
        current_price: extractNumber(
 | 
			
		||||
          $(el).find('div.max-bid-price.price').text(),
 | 
			
		||||
        ),
 | 
			
		||||
        scrap_config_id: this.scrap_config_id,
 | 
			
		||||
      } as ScrapItem;
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    console.log({ results });
 | 
			
		||||
    return results;
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -6,9 +6,15 @@ import { ScrapConfigsService } from './services/scrap-config.service';
 | 
			
		|||
import { ScrapConfigsController } from './controllers/scrap-config.controller';
 | 
			
		||||
import { TasksService } from './services/tasks.service';
 | 
			
		||||
import { ScrapItemsService } from './services/scrap-item-config.service';
 | 
			
		||||
import { MailsModule } from '../mails/mails.module';
 | 
			
		||||
import { BidsModule } from '../bids/bids.module';
 | 
			
		||||
 | 
			
		||||
@Module({
 | 
			
		||||
  imports: [TypeOrmModule.forFeature([ScrapConfig, ScrapItem])],
 | 
			
		||||
  imports: [
 | 
			
		||||
    TypeOrmModule.forFeature([ScrapConfig, ScrapItem]),
 | 
			
		||||
    MailsModule,
 | 
			
		||||
    BidsModule,
 | 
			
		||||
  ],
 | 
			
		||||
  providers: [ScrapConfigsService, TasksService, ScrapItemsService],
 | 
			
		||||
  exports: [ScrapConfigsService, TasksService, ScrapItemsService],
 | 
			
		||||
  controllers: [ScrapConfigsController],
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -8,7 +8,8 @@ import { CreateScrapConfigDto } from '../dto/scrap-config/create-scrap-config';
 | 
			
		|||
import { UpdateScrapConfigDto } from '../dto/scrap-config/update-scrap-config';
 | 
			
		||||
import axios from 'axios';
 | 
			
		||||
import { WebBid } from '@/modules/bids/entities/wed-bid.entity';
 | 
			
		||||
import { GraysScrapModel } from '../models/https:/www.grays.com/grays-scrap-model';
 | 
			
		||||
import { GraysScrapModel } from '../models/www.grays.com/grays-scrap-model';
 | 
			
		||||
import { LangtonsScrapModel } from '../models/www.langtons.com.au/langtons-scrap-model';
 | 
			
		||||
 | 
			
		||||
@Injectable()
 | 
			
		||||
export class ScrapConfigsService {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -9,4 +9,53 @@ export class ScrapItemsService {
 | 
			
		|||
    @InjectRepository(ScrapItem)
 | 
			
		||||
    readonly scrapItemRepo: Repository<ScrapItem>,
 | 
			
		||||
  ) {}
 | 
			
		||||
 | 
			
		||||
  async upsertScrapItems(items: ScrapItem[]) {
 | 
			
		||||
    if (!items.length) return { inserted: 0, updated: 0 };
 | 
			
		||||
 | 
			
		||||
    // Lấy keys để query bản ghi đã tồn tại
 | 
			
		||||
    const keys = items.map((d) => ({
 | 
			
		||||
      model: d.model,
 | 
			
		||||
      scrap_config_id: d.scrap_config_id,
 | 
			
		||||
    }));
 | 
			
		||||
 | 
			
		||||
    // Tìm các bản ghi đã có trong DB
 | 
			
		||||
    const existingItems = await this.scrapItemRepo.findBy(keys);
 | 
			
		||||
 | 
			
		||||
    const toUpdate = [];
 | 
			
		||||
    const toInsert = [];
 | 
			
		||||
 | 
			
		||||
    items.forEach((itemData) => {
 | 
			
		||||
      const exist = existingItems.find(
 | 
			
		||||
        (e) =>
 | 
			
		||||
          e.model === itemData.model &&
 | 
			
		||||
          e.scrap_config_id === itemData.scrap_config_id,
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      if (exist) {
 | 
			
		||||
        toUpdate.push({
 | 
			
		||||
          ...exist,
 | 
			
		||||
          ...itemData,
 | 
			
		||||
          updated_at: new Date(),
 | 
			
		||||
        });
 | 
			
		||||
      } else {
 | 
			
		||||
        toInsert.push(itemData);
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // Thực hiện insert
 | 
			
		||||
    if (toInsert.length) {
 | 
			
		||||
      await this.scrapItemRepo.insert(toInsert);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Thực hiện update
 | 
			
		||||
    if (toUpdate.length) {
 | 
			
		||||
      await this.scrapItemRepo.save(toUpdate);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
      inserted: toInsert.length,
 | 
			
		||||
      updated: toUpdate.length,
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,25 +1,72 @@
 | 
			
		|||
import { Injectable, Logger } from '@nestjs/common';
 | 
			
		||||
import { Cron, CronExpression } from '@nestjs/schedule';
 | 
			
		||||
import { IsNull, Not } from 'typeorm';
 | 
			
		||||
import { Between, IsNull, Not } from 'typeorm';
 | 
			
		||||
import { ScrapConfigsService } from './scrap-config.service';
 | 
			
		||||
import { ScrapItemsService } from './scrap-item-config.service';
 | 
			
		||||
import { MailsService } from '@/modules/mails/services/mails.service';
 | 
			
		||||
import { ConfigsService } from '@/modules/bids/services/configs.service';
 | 
			
		||||
import * as moment from 'moment';
 | 
			
		||||
 | 
			
		||||
@Injectable()
 | 
			
		||||
export class TasksService {
 | 
			
		||||
  private readonly logger = new Logger(TasksService.name);
 | 
			
		||||
 | 
			
		||||
  constructor(private readonly scrapConfigsService: ScrapConfigsService) {}
 | 
			
		||||
  constructor(
 | 
			
		||||
    private readonly scrapConfigsService: ScrapConfigsService,
 | 
			
		||||
    private readonly scrapItemsService: ScrapItemsService,
 | 
			
		||||
    private readonly mailsService: MailsService,
 | 
			
		||||
    private readonly configsSerivce: ConfigsService,
 | 
			
		||||
  ) {}
 | 
			
		||||
 | 
			
		||||
  @Cron(CronExpression.EVERY_MINUTE)
 | 
			
		||||
  @Cron('0 2 * * *')
 | 
			
		||||
  async handleScraps() {
 | 
			
		||||
    // const scrapConfigs = await this.scrapConfigsService.scrapConfigRepo.find({
 | 
			
		||||
    //   where: {
 | 
			
		||||
    //     search_url: Not(IsNull()),
 | 
			
		||||
    //     keywords: Not(IsNull()),
 | 
			
		||||
    //   },
 | 
			
		||||
    //   relations: {
 | 
			
		||||
    //     web_bid: true,
 | 
			
		||||
    //   },
 | 
			
		||||
    // });
 | 
			
		||||
    // const models = this.scrapConfigsService.scrapModels(scrapConfigs);
 | 
			
		||||
    // await Promise.allSettled(models.map(async (item) => await item.action()));
 | 
			
		||||
    const mails = (await this.configsSerivce.getConfig('MAIL_SCRAP_REPORT'))
 | 
			
		||||
      .value;
 | 
			
		||||
 | 
			
		||||
    if (!mails) return;
 | 
			
		||||
 | 
			
		||||
    const scrapConfigs = await this.scrapConfigsService.scrapConfigRepo.find({
 | 
			
		||||
      where: {
 | 
			
		||||
        search_url: Not(IsNull()),
 | 
			
		||||
        keywords: Not(IsNull()),
 | 
			
		||||
      },
 | 
			
		||||
      relations: {
 | 
			
		||||
        web_bid: true,
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
    const models = this.scrapConfigsService.scrapModels(scrapConfigs);
 | 
			
		||||
    await Promise.allSettled(
 | 
			
		||||
      models.map(async (item) => {
 | 
			
		||||
        await item.action();
 | 
			
		||||
        for (const key of Object.keys(item.results)) {
 | 
			
		||||
          const dataArray = item.results[key];
 | 
			
		||||
          const result =
 | 
			
		||||
            await this.scrapItemsService.upsertScrapItems(dataArray);
 | 
			
		||||
          console.log(result);
 | 
			
		||||
        }
 | 
			
		||||
      }),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    const startOfDay = new Date();
 | 
			
		||||
    startOfDay.setHours(0, 0, 0, 0);
 | 
			
		||||
 | 
			
		||||
    const endOfDay = new Date();
 | 
			
		||||
    endOfDay.setHours(23, 59, 59, 999);
 | 
			
		||||
 | 
			
		||||
    const data = await this.scrapItemsService.scrapItemRepo.find({
 | 
			
		||||
      where: {
 | 
			
		||||
        updated_at: Between(startOfDay, endOfDay),
 | 
			
		||||
      },
 | 
			
		||||
      relations: { scrap_config: { web_bid: true } },
 | 
			
		||||
      order: { updated_at: 'DESC' },
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    await this.mailsService.sendPlainHtml(
 | 
			
		||||
      mails,
 | 
			
		||||
      `Auction Items Matching Your Keywords – Daily Update ${moment(new Date()).format('YYYY-MM-DD HH:mm')}`,
 | 
			
		||||
      this.mailsService.generateProductTableHTML(data),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    console.log('Send report success');
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
		Reference in New Issue