import type { HttpContext } from '@adonisjs/core/http' import Auction from '#models/auction' import ParseLog from '#models/parse_log' import { errors as lucidErrors } from '@adonisjs/lucid' import { DateTime } from 'luxon' import { ScraperService } from '#services/scraper_service' import { NotificationService } from '#services/notification_service' import logger from '@adonisjs/core/services/logger' export default class AuctionsController { /** * List auctions with pagination * GET /api/auctions?page=1&limit=20 */ async index({ request, response }: HttpContext) { const page = request.input('page', 1) const limit = request.input('limit', 20) const pageNumber = Math.max(1, Number(page)) const limitNumber = Math.min(100, Math.max(1, Number(limit))) try { const auctions = await Auction.query() .orderBy('created_at', 'desc') .paginate(pageNumber, limitNumber) return response.ok(auctions.serialize()) } catch (error) { return response.internalServerError({ error: 'Failed to fetch auctions', message: error instanceof Error ? error.message : 'Unknown error', }) } } /** * Get a single auction by id with relationships * GET /api/auctions/:id */ async show({ params, response }: HttpContext) { const auctionId = params.id if (!auctionId || isNaN(Number(auctionId))) { return response.badRequest({ error: 'Invalid auction id', }) } try { const auction = await Auction.query() .where('id', auctionId) .preload('notifications') .firstOrFail() return response.ok(auction.serialize()) } catch (error) { if (error instanceof lucidErrors.E_ROW_NOT_FOUND) { return response.notFound({ error: 'Auction not found', }) } return response.internalServerError({ error: 'Failed to fetch auction', message: error instanceof Error ? error.message : 'Unknown error', }) } } /** * Search auctions by keyword/title/status * GET /api/auctions/search?q=keyword&status=active&page=1&limit=20 */ async search({ request, response }: HttpContext) { const searchQuery = request.input('q', '') const status = request.input('status') const page = request.input('page', 1) const limit = request.input('limit', 20) const pageNumber = Math.max(1, Number(page)) const limitNumber = Math.min(100, Math.max(1, Number(limit))) try { const query = Auction.query() if (searchQuery && typeof searchQuery === 'string' && searchQuery.trim().length > 0) { const trimmedQuery = searchQuery.trim() query.where((builder) => { builder .whereILike('title', `%${trimmedQuery}%`) .orWhereILike('description', `%${trimmedQuery}%`) .orWhereILike('auction_num', `%${trimmedQuery}%`) .orWhereILike('organization', `%${trimmedQuery}%`) }) } if (status && typeof status === 'string' && status.trim().length > 0) { query.where('status', status.trim()) } const auctions = await query.orderBy('created_at', 'desc').paginate(pageNumber, limitNumber) return response.ok(auctions.serialize()) } catch (error) { return response.internalServerError({ error: 'Failed to search auctions', message: error instanceof Error ? error.message : 'Unknown error', }) } } /** * List all auctions from last 3 days * GET /list */ async list({ response }: HttpContext) { try { const threeDaysAgo = DateTime.now().minus({ days: 3 }) const auctions = await Auction.query() .where('created_at', '>=', threeDaysAgo.toSQL()) .orderBy('created_at', 'desc') return response.ok({ data: auctions.map((auction) => auction.serialize()), meta: { total: auctions.length, from_date: threeDaysAgo.toISO(), }, }) } catch (error) { return response.internalServerError({ error: 'Failed to fetch recent auctions', message: error instanceof Error ? error.message : 'Unknown error', }) } } /** * Render HTML view of auctions from last 3 days * GET /list-view */ async listView({ view }: HttpContext) { const threeDaysAgo = DateTime.now().minus({ days: 3 }) const auctions = await Auction.query() .where('created_at', '>=', threeDaysAgo.toSQL()) .orderBy('created_at', 'desc') return view.render('auctions/list', { auctions, fromDate: threeDaysAgo.toFormat('dd.MM.yyyy HH:mm'), }) } /** * Trigger auction parsing from web interface * POST /trigger-parse */ async triggerParse({ request, response, session }: HttpContext) { try { // Validate input const pages = request.input('pages', 1) const notifySubscribers = request.input('notifySubscribers') === 'on' const pagesNumber = Math.max(1, Math.min(10, Number(pages))) if (isNaN(pagesNumber)) { session.flash('error', 'Invalid number of pages') return response.redirect('/list-view') } logger.info(`Parsing triggered from web interface`, { pages: pagesNumber, notifySubscribers, }) const startTime = DateTime.now() const scraper = new ScraperService() const notificationService = new NotificationService() // Create ParseLog entry const parseLog = await ParseLog.create({ parseType: 'auction', status: 'running', itemsFound: 0, errors: null, startedAt: startTime, completedAt: null, }) let newAuctions = 0 let updatedAuctions = 0 let totalScraped = 0 const errors: string[] = [] // Step 1: Scrape auctions logger.info(`Scraping ${pagesNumber} page(s) from icetrade.by`) const auctionData = await scraper.scrapeAuctions(pagesNumber) totalScraped = auctionData.length if (totalScraped === 0) { await this.updateParseLog(parseLog, 'completed', 0, null) session.flash('error', 'No auctions found during parsing') return response.redirect('/list-view') } // Step 2: Upsert auctions to database logger.info(`Saving ${totalScraped} auction(s) to database`) for (const data of auctionData) { try { // Check if auction exists by auctionNum const existingAuction = await Auction.query() .where('auctionNum', data.auctionNum) .first() let auction: Auction if (existingAuction) { // Update existing auction existingAuction.title = data.title existingAuction.description = data.description existingAuction.organization = data.organization existingAuction.status = data.status existingAuction.deadline = data.deadline ? DateTime.fromISO(data.deadline) : null existingAuction.url = data.link existingAuction.rawData = data await existingAuction.save() auction = existingAuction updatedAuctions++ logger.debug(`Updated auction: ${data.auctionNum}`) } else { // Create new auction auction = await Auction.create({ auctionNum: data.auctionNum, title: data.title, description: data.description, organization: data.organization, status: data.status, price: null, deadline: data.deadline ? DateTime.fromISO(data.deadline) : null, url: data.link, rawData: data, }) newAuctions++ logger.debug(`Created auction: ${data.auctionNum}`) // Step 3: Check for keyword matches and send notifications (for new auctions only) if (notifySubscribers) { try { await notificationService.checkAndNotify(auction) } catch (error) { const errorMsg = `Notification check failed for auction ${data.auctionNum}: ${error instanceof Error ? error.message : String(error)}` logger.error(errorMsg) errors.push(errorMsg) // Don't fail the entire process if notification fails } } } } catch (error) { const errorMsg = `Failed to save auction ${data.auctionNum}: ${error instanceof Error ? error.message : String(error)}` logger.error(errorMsg) errors.push(errorMsg) } } // Update ParseLog with success status const status = errors.length > 0 ? 'completed_with_errors' : 'completed' await this.updateParseLog( parseLog, status, newAuctions + updatedAuctions, errors.length > 0 ? errors.join('\n') : null ) // Create success message let successMessage = `Parsing completed: ${totalScraped} auctions scraped, ${newAuctions} new, ${updatedAuctions} updated` if (!notifySubscribers) { successMessage += ' (notifications disabled)' } if (errors.length > 0) { successMessage += ` with ${errors.length} error(s)` } session.flash('success', successMessage) logger.info(successMessage) return response.redirect('/list-view') } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error during parsing' logger.error('Error in triggerParse', { error: errorMessage, }) session.flash('error', `Parsing failed: ${errorMessage}`) return response.redirect('/list-view') } } /** * Update ParseLog entry with final status */ private async updateParseLog( parseLog: ParseLog, status: string, itemsFound: number, errors: string | null ): Promise { parseLog.status = status parseLog.itemsFound = itemsFound parseLog.errors = errors parseLog.completedAt = DateTime.now() await parseLog.save() logger.info(`Updated ParseLog ${parseLog.id}: status=${status}, items=${itemsFound}`) } }