325 lines
10 KiB
TypeScript
325 lines
10 KiB
TypeScript
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<void> {
|
|
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}`)
|
|
}
|
|
}
|