224 lines
7.3 KiB
TypeScript
224 lines
7.3 KiB
TypeScript
import logger from '@adonisjs/core/services/logger'
|
|
import { DateTime } from 'luxon'
|
|
import Auction from '#models/auction'
|
|
import Keyword from '#models/keyword'
|
|
import Notification from '#models/notification'
|
|
import User from '#models/user'
|
|
import { TelegramService } from '#services/telegram_service'
|
|
|
|
/**
|
|
* NotificationService - Handles keyword matching and notification delivery
|
|
*
|
|
* Features:
|
|
* - Matches keywords against auction titles and descriptions
|
|
* - Case-sensitive and case-insensitive matching support
|
|
* - Prevents duplicate notifications (checks existing records)
|
|
* - Tracks notification status (pending/sent/failed)
|
|
* - Integrates with TelegramService for message delivery
|
|
* - Validates user active status before sending
|
|
*/
|
|
export class NotificationService {
|
|
/**
|
|
* Check auction against all active keywords and send notifications for matches
|
|
*
|
|
* @param auction - The auction to check for keyword matches
|
|
*/
|
|
async checkAndNotify(auction: Auction): Promise<void> {
|
|
try {
|
|
logger.info(`Checking auction ${auction.auctionNum} for keyword matches`)
|
|
|
|
// Get all active keywords
|
|
const keywords = await Keyword.query().where('isActive', true).preload('user')
|
|
|
|
if (keywords.length === 0) {
|
|
logger.debug('No active keywords found, skipping notification check')
|
|
return
|
|
}
|
|
|
|
logger.debug(`Found ${keywords.length} active keyword(s) to check`)
|
|
|
|
let matchCount = 0
|
|
|
|
for (const keyword of keywords) {
|
|
try {
|
|
// Check if keyword matches auction title or description
|
|
const titleMatch = this.matchKeyword(
|
|
auction.title,
|
|
keyword.keyword,
|
|
keyword.caseSensitive
|
|
)
|
|
|
|
const descMatch = auction.description
|
|
? this.matchKeyword(auction.description, keyword.keyword, keyword.caseSensitive)
|
|
: false
|
|
|
|
if (!titleMatch && !descMatch) {
|
|
continue // No match, skip this keyword
|
|
}
|
|
|
|
matchCount++
|
|
logger.info(
|
|
`Keyword match found: "${keyword.keyword}" in auction ${auction.auctionNum}`,
|
|
{
|
|
keywordId: keyword.id,
|
|
auctionId: auction.id,
|
|
matchedIn: titleMatch ? 'title' : 'description',
|
|
}
|
|
)
|
|
|
|
// Check if notification already exists (prevent duplicates)
|
|
const existingNotification = await Notification.query()
|
|
.where('auctionId', auction.id)
|
|
.where('keywordId', keyword.id)
|
|
.first()
|
|
|
|
if (existingNotification) {
|
|
logger.debug(
|
|
`Notification already exists for auction ${auction.id} and keyword ${keyword.id}, skipping`,
|
|
{
|
|
notificationId: existingNotification.id,
|
|
status: existingNotification.status,
|
|
}
|
|
)
|
|
continue
|
|
}
|
|
|
|
// Create notification record with 'pending' status
|
|
const notification = await Notification.create({
|
|
auctionId: auction.id,
|
|
keywordId: keyword.id,
|
|
status: 'pending',
|
|
errorMessage: null,
|
|
sentAt: null,
|
|
})
|
|
|
|
logger.debug(`Created notification record ${notification.id}`)
|
|
|
|
// Get user and validate active status
|
|
const user = await User.find(keyword.userId)
|
|
|
|
if (!user) {
|
|
logger.warn(`User ${keyword.userId} not found for keyword ${keyword.id}`)
|
|
await notification
|
|
.merge({
|
|
status: 'failed',
|
|
errorMessage: 'User not found',
|
|
})
|
|
.save()
|
|
continue
|
|
}
|
|
|
|
if (!user.telegramChatId) {
|
|
logger.warn(`User ${user.id} has no Telegram chat ID`)
|
|
await notification
|
|
.merge({
|
|
status: 'failed',
|
|
errorMessage: 'User has no Telegram chat ID',
|
|
})
|
|
.save()
|
|
continue
|
|
}
|
|
|
|
// Send notification via Telegram
|
|
try {
|
|
const telegramService = TelegramService.getInstance()
|
|
const auctionUrl = auction.url || `https://icetrade.by/auction/${auction.auctionNum}`
|
|
|
|
const success = await telegramService.sendNotification(
|
|
user.telegramChatId,
|
|
auction.title,
|
|
auctionUrl,
|
|
keyword.keyword
|
|
)
|
|
|
|
if (success) {
|
|
// Update notification status to 'sent'
|
|
await notification
|
|
.merge({
|
|
status: 'sent',
|
|
sentAt: DateTime.now(),
|
|
})
|
|
.save()
|
|
|
|
logger.info(`Notification sent successfully`, {
|
|
notificationId: notification.id,
|
|
userId: user.id,
|
|
chatId: user.telegramChatId,
|
|
})
|
|
} else {
|
|
// Update notification status to 'failed'
|
|
await notification
|
|
.merge({
|
|
status: 'failed',
|
|
errorMessage: 'Failed to send Telegram message (unknown error)',
|
|
})
|
|
.save()
|
|
|
|
logger.error(`Failed to send notification`, {
|
|
notificationId: notification.id,
|
|
userId: user.id,
|
|
chatId: user.telegramChatId,
|
|
})
|
|
}
|
|
} catch (error) {
|
|
const errorMessage =
|
|
error instanceof Error ? error.message : 'Unknown error during notification send'
|
|
|
|
logger.error(`Error sending notification`, {
|
|
notificationId: notification.id,
|
|
error: errorMessage,
|
|
userId: user.id,
|
|
})
|
|
|
|
// Update notification status to 'failed' with error details
|
|
await notification
|
|
.merge({
|
|
status: 'failed',
|
|
errorMessage,
|
|
})
|
|
.save()
|
|
}
|
|
} catch (error) {
|
|
logger.error(`Error processing keyword ${keyword.id} for auction ${auction.id}`, {
|
|
error: error instanceof Error ? error.message : String(error),
|
|
keywordId: keyword.id,
|
|
auctionId: auction.id,
|
|
})
|
|
// Continue processing other keywords even if one fails
|
|
continue
|
|
}
|
|
}
|
|
|
|
logger.info(
|
|
`Completed notification check for auction ${auction.auctionNum}: ${matchCount} match(es) found`
|
|
)
|
|
} catch (error) {
|
|
logger.error(`Error in checkAndNotify for auction ${auction.id}`, {
|
|
error: error instanceof Error ? error.message : String(error),
|
|
auctionId: auction.id,
|
|
})
|
|
// Don't throw - allow scraper to continue processing other auctions
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if a keyword matches text with case-sensitive or case-insensitive matching
|
|
*
|
|
* @param text - The text to search in
|
|
* @param keyword - The keyword to search for
|
|
* @param caseSensitive - Whether to perform case-sensitive matching
|
|
* @returns true if keyword is found in text, false otherwise
|
|
*/
|
|
private matchKeyword(text: string, keyword: string, caseSensitive: boolean): boolean {
|
|
if (!text || !keyword) {
|
|
return false
|
|
}
|
|
|
|
if (caseSensitive) {
|
|
return text.includes(keyword)
|
|
}
|
|
|
|
return text.toLowerCase().includes(keyword.toLowerCase())
|
|
}
|
|
}
|