472 lines
14 KiB
TypeScript
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>
|
|
`;
|
|
}
|
|
}
|