bid-tool/auto-bid-server/src/modules/mails/services/mails.service.ts

472 lines
14 KiB
TypeScript

import { Bid } from '@/modules/bids/entities/bid.entity';
import { BidMetadatasService } from '@/modules/bids/services/bid-metadatas.service';
import { BidsService } from '@/modules/bids/services/bids.service';
import { ScrapItem } from '@/modules/scraps/entities/scrap-item.entity';
import {
extractDomain,
extractDomainSmart,
formatEndTime,
isHotItemFn,
isTimeReached,
} from '@/ultils';
import { MailerService } from '@nestjs-modules/mailer';
import { InjectQueue } from '@nestjs/bull';
import { Injectable } from '@nestjs/common';
import { Queue } from 'bull';
import * as _ from 'lodash';
@Injectable()
export class MailsService {
constructor(
private readonly mailerService: MailerService,
@InjectQueue('mail-queue') private mailQueue: Queue,
private readonly bidsService: BidsService,
private readonly bidMetadataService: BidMetadatasService,
) {}
async sendPlainText(to: string, subject: string, content: string) {
await this.mailerService.sendMail({
to,
subject,
text: content,
});
}
async sendHtmlMailJob(mailData: {
to: string;
subject: string;
html: string;
}) {
await this.mailQueue.add('send-mail', mailData);
}
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[],
hotItems: { name: string }[],
): {
html: string;
hasHotItem: boolean;
} {
const from = process.env.MAIL_USER || 'no-reply@example.com';
let hasHotItem = false;
if (!products.length) {
return {
html: `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Product Listing</title>
</head>
<body style="font-family: sans-serif; background: #f8f9fa; padding: 20px;">
<h2 style="text-align: center; color: #333;">Product Listing</h2>
<p style="text-align: center; color: #666;">No matching products found for your keywords today.</p>
<p style="text-align: center; color: #999; font-size: 12px; margin-top: 40px;">From: ${from}</p>
</body>
</html>
`,
hasHotItem,
};
}
// Decorate products with isHotItem
const decorated = products.map((p) => {
const isHotItem = hotItems.some((obj) =>
p.name.toLowerCase().includes(obj.name.toLowerCase()),
);
if (isHotItem) hasHotItem = true;
return {
...p,
isHotItem,
isNew:
new Date(p.created_at).getTime() === new Date(p.updated_at).getTime(),
};
});
// Separate hot and non-hot items, preserving original order
const hotItemsList = decorated.filter((p) => p.isHotItem);
const nonHotItems = decorated.filter((p) => !p.isHotItem);
// Đưa các item mới (isNew) lên đầu trong nhóm không phải hot item
const sortedNonHotItems = [
...nonHotItems.filter((p) => p.isNew),
...nonHotItems.filter((p) => !p.isNew),
];
// Separate hot and non-hot items, preserving original order
const hotItemsFirst = [...hotItemsList, ...sortedNonHotItems];
const rows = hotItemsFirst
.map((p) => {
const isHotItem = isHotItemFn(p, hotItems);
if (isHotItem) {
hasHotItem = true;
}
const isNew =
new Date(p.created_at).getTime() === new Date(p.updated_at).getTime();
const nameStyle = isNew ? 'color: #2f9e44; font-weight: bold;' : '';
const namePrefix = isHotItem
? '<span style="font-weight: bold; color: #e03131;">[HOT ITEM]</span> '
: '';
return `
<tr ${isHotItem ? 'style="background-color: #ff848496;margin-bottom: 4px;"' : 'margin-bottom: 4px;'}>
<td style="display:flex;max-width: 60px;"><img src="${p.image_url}" alt="Product Image" style="height: 60px !important;width:60px !important; object-fit: contain; border-radius: 4px;" /></td>
<td style="${nameStyle}">${namePrefix}${p.name}</td>
<td style="font-weight: bold; color: #e03131;">${p.current_price ? '$' + p.current_price : 'None'}</td>
<td><a href="${p.url}" target="_blank" style="color: #007bff;">View</a></td>
<td>${extractDomainSmart(p.scrap_config.web_bid.origin_url)}</td>
</tr>
`;
})
.join('');
const title = hasHotItem
? '<span style="font-weight: bold; color: #e03131;">[HOT ITEMS]</span> Product Listing'
: 'Product Listing';
return {
html: `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>${title}</title>
</head>
<body style="font-family: sans-serif; background: #f8f9fa; padding: 20px;">
<h2 style="text-align: center; color: #333;">${title}</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>
<p style="text-align: center; color: #999; font-size: 12px; margin-top: 40px;">From: ${from}</p>
</body>
</html>
`,
hasHotItem,
};
}
getAuctionStatusEmailContent(bid: Bid): string {
const webname = extractDomain(bid.web_bid.origin_url);
const title = `[${webname}] ${bid.name || 'Unnamed Item'}`;
const endTime = formatEndTime(bid.close_time, false);
const competitor = `$${bid.current_price}`;
const max = `$${bid.max_price}`;
const submitted = `$${bid.max_price}`;
const nextBid = bid.max_price + bid.plus_price;
const from = process.env.MAIL_USER || 'no-reply@example.com';
const cardStyle = `
max-width: 600px;
margin: 20px auto;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: #ffffff;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
color: #333;
padding: 20px;
`;
const headerStyle = (color: string) =>
`font-size: 22px; font-weight: 700; color: ${color}; margin-bottom: 15px;`;
const labelStyle = `font-weight: 600; width: 120px; display: inline-block; color: #555;`;
const valueStyle = `color: #222;`;
const renderRow = (label: string, value: string) =>
`<p><span style="${labelStyle}">${label}:</span> <span style="${valueStyle}">${value}</span></p>`;
switch (bid.status) {
case 'biding':
return `
<div style="${cardStyle}">
<h2 style="${headerStyle('#2c7a7b')}">✅ Auto Bid Started</h2>
${renderRow('Title', title)}
${renderRow('Max', max)}
${renderRow('End time', endTime)}
${renderRow('Competitor', competitor)}
${renderRow('Bid submitted', submitted)}
${renderRow('From', from)}
</div>
`;
case 'out-bid': {
const overLimit = bid.current_price >= nextBid;
const belowReserve = bid.reserve_price > nextBid;
const timeExtended = bid.close_time ? 'Time extended' : 'No extension';
if (isTimeReached(bid.close_time)) {
return `
<div style="${cardStyle}">
<h2 style="${headerStyle('#718096')}">⏳ Auction Ended</h2>
${renderRow('Title', title)}
${renderRow('End time', endTime)}
${renderRow('Final price', competitor)}
${renderRow('From', from)}
</div>
`;
}
if (overLimit || belowReserve) {
return `
<div style="${cardStyle}">
<h2 style="${headerStyle('#dd6b20')}">⚠️ Outbid (${timeExtended})</h2>
${renderRow('Title', title)}
${renderRow('Competitor', competitor)}
${renderRow('Max', max)}
${renderRow('Next bid at', `$${nextBid}`)}
${renderRow('End time', endTime)}
${renderRow('From', from)}
<p style="color:#c05621; font-weight: 600;">⚠️ Current bid exceeds your max bid.</p>
</div>
`;
}
return `
<div style="${cardStyle}">
<h2 style="${headerStyle('#e53e3e')}">🛑 Auction Canceled (${timeExtended})</h2>
${renderRow('Title', title)}
${renderRow('Competitor', competitor)}
${renderRow('Max', max)}
${renderRow('Next bid at', `$${nextBid}`)}
${renderRow('End time', endTime)}
${renderRow('From', from)}
<p style="color:#9b2c2c; font-weight: 600;">🛑 Auction has been canceled.</p>
</div>
`;
}
case 'win-bid':
return `
<div style="${cardStyle}">
<h2 style="${headerStyle('#2b6cb0')}">🎉 You Won!</h2>
${renderRow('Title', title)}
${renderRow('Price won', `$${bid.current_price}`)}
${renderRow('Max', max)}
${renderRow('From', from)}
</div>
`;
default:
return `
<div style="${cardStyle}">
<h2 style="${headerStyle('#718096')}">❓ Unknown Status</h2>
${renderRow('Title', title)}
${renderRow('From', from)}
</div>
`;
}
}
getBidSubmittedEmailContent(bid: Bid): string {
const webname = extractDomain(bid.web_bid.origin_url);
const title = `[${webname}] ${bid.name || 'Unnamed Item'}`;
const endTime = formatEndTime(bid.close_time, false);
const competitor = `$${bid.current_price}`;
const max = `$${bid.max_price}`;
const submitted = `$${bid.max_price}`;
const maxReached = bid.max_price <= bid.max_price;
const from = process.env.MAIL_USER || 'no-reply@example.com';
return `
<!DOCTYPE html>
<html lang="en">
<head>
<style>
body {
font-family: Arial, sans-serif;
background: #f9f9f9;
color: #333;
padding: 20px;
}
.container {
background: #fff;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
max-width: 600px;
margin: auto;
}
h2 {
color: #007bff;
text-align: center;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 15px;
}
th, td {
padding: 10px;
border-bottom: 1px solid #ddd;
text-align: left;
}
th {
background-color: #f1f1f1;
color: #555;
}
.highlight {
color: #e03131;
font-weight: bold;
}
.max-reach {
color: #d6336c;
font-weight: bold;
}
</style>
</head>
<body>
<div class="container">
<h2>Bid Submitted${bid.close_time ? ', Time extended' : ', No extension'}${maxReached ? ' <span class="max-reach">* MAX REACH *</span>' : ''}</h2>
<table>
<tbody>
<tr>
<th>Title</th>
<td>${title}</td>
</tr>
<tr>
<th>Competitor</th>
<td>${competitor}</td>
</tr>
<tr>
<th>Bid Submitted</th>
<td>${submitted} ${maxReached ? '<span class="max-reach">(<b>***MAXIMUM REACH***</b>)</span>' : ''}</td>
</tr>
<tr>
<th>Max</th>
<td class="highlight">${max}</td>
</tr>
<tr>
<th>End Time</th>
<td>${endTime}</td>
</tr>
<tr>
<th>From</th>
<td>${from}</td>
</tr>
</tbody>
</table>
</div>
</body>
</html>
`;
}
async getBidRecordEmailContent(bid: Bid, type: 'action' | 'api') {
let recordData: {
bid_id: number;
name: string;
original_name: string;
timestamp: number;
}[] = [];
try {
const data = await this.bidsService.getRecords(bid.id);
recordData = data.data || [];
} catch (error) {
recordData = [];
}
const json = this.bidMetadataService.getDemoResponse(bid.metadata);
const videoUrl = this.bidMetadataService.getLatestRecordLink(bid.metadata);
const closeTime = bid.close_time
? new Date(bid.close_time).toLocaleString('en-AU', {
timeZone: 'Australia/Sydney',
dateStyle: 'medium',
timeStyle: 'short',
})
: 'Unknown';
let content = '';
if (type === 'action') {
content = `
<h3>📹 Record Preview</h3>
<p><strong>Name:</strong> ${bid?.name}</p>
<p><strong>Recorded:</strong> ${new Date().toLocaleString()}</p>
<p>
<a href="${videoUrl || ''}" target="_blank" style="
display: inline-block;
padding: 10px 16px;
background-color: #007bff;
color: #fff;
text-decoration: none;
border-radius: 4px;
margin-top: 12px;
">
▶️ Watch Video
</a>
</p>
`;
} else if (type === 'api') {
content = `
<h3>📦 Record JSON</h3>
<pre style="
background-color: #f4f4f4;
padding: 12px;
border-radius: 4px;
font-size: 13px;
overflow-x: auto;
">${JSON.stringify(json, null, 2)}</pre>
`;
}
return `
<div style="font-family: Arial, sans-serif; color: #333; max-width: 600px; margin: 0 auto;">
<h2>📝 Bid Demo: ${bid?.name}</h2>
<p><strong>⏰ Close Time:</strong> ${closeTime}</p>
${
content
? content
: `<p style="color: red;">⚠️ No record available for this bid.</p>`
}
<hr style="margin: 24px 0;">
<p style="font-size: 12px; color: #888;">This is an automated message. Please do not reply.</p>
</div>
`;
}
}