auction-scrapper/app/services/notification_service.ts
Vakula Uladimir 12f005e335 init
2025-10-17 11:27:52 +03:00

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