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 { 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()) } }