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: ` Product Listing

Product Listing

No matching products found for your keywords today.

From: ${from}

`, 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 ? '[HOT ITEM] ' : ''; return ` Product Image ${namePrefix}${p.name} ${p.current_price ? '$' + p.current_price : 'None'} View ${extractDomainSmart(p.scrap_config.web_bid.origin_url)} `; }) .join(''); const title = hasHotItem ? '[HOT ITEMS] Product Listing' : 'Product Listing'; return { html: ` ${title}

${title}

${rows}
Image Name Price Link Domain

From: ${from}

`, 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) => `

${label}: ${value}

`; switch (bid.status) { case 'biding': return `

✅ Auto Bid Started

${renderRow('Title', title)} ${renderRow('Max', max)} ${renderRow('End time', endTime)} ${renderRow('Competitor', competitor)} ${renderRow('Bid submitted', submitted)} ${renderRow('From', from)}
`; 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 `

⏳ Auction Ended

${renderRow('Title', title)} ${renderRow('End time', endTime)} ${renderRow('Final price', competitor)} ${renderRow('From', from)}
`; } if (overLimit || belowReserve) { return `

⚠️ Outbid (${timeExtended})

${renderRow('Title', title)} ${renderRow('Competitor', competitor)} ${renderRow('Max', max)} ${renderRow('Next bid at', `$${nextBid}`)} ${renderRow('End time', endTime)} ${renderRow('From', from)}

⚠️ Current bid exceeds your max bid.

`; } return `

🛑 Auction Canceled (${timeExtended})

${renderRow('Title', title)} ${renderRow('Competitor', competitor)} ${renderRow('Max', max)} ${renderRow('Next bid at', `$${nextBid}`)} ${renderRow('End time', endTime)} ${renderRow('From', from)}

🛑 Auction has been canceled.

`; } case 'win-bid': return `

🎉 You Won!

${renderRow('Title', title)} ${renderRow('Price won', `$${bid.current_price}`)} ${renderRow('Max', max)} ${renderRow('From', from)}
`; default: return `

❓ Unknown Status

${renderRow('Title', title)} ${renderRow('From', from)}
`; } } 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 `

Bid Submitted${bid.close_time ? ', Time extended' : ', No extension'}${maxReached ? ' * MAX REACH *' : ''}

Title ${title}
Competitor ${competitor}
Bid Submitted ${submitted} ${maxReached ? '(***MAXIMUM REACH***)' : ''}
Max ${max}
End Time ${endTime}
From ${from}
`; } 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 = `

📹 Record Preview

Name: ${bid?.name}

Recorded: ${new Date().toLocaleString()}

▶️ Watch Video

`; } else if (type === 'api') { content = `

📦 Record JSON

${JSON.stringify(json, null, 2)}
`; } return `

📝 Bid Demo: ${bid?.name}

⏰ Close Time: ${closeTime}

${ content ? content : `

⚠️ No record available for this bid.

` }

This is an automated message. Please do not reply.

`; } }