init
This commit is contained in:
@@ -0,0 +1,324 @@
|
||||
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}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import Keyword from '#models/keyword'
|
||||
|
||||
export default class KeywordsController {
|
||||
/**
|
||||
* List keywords by user_id with optional filtering by is_active
|
||||
* GET /api/keywords?user_id=1&is_active=true
|
||||
*/
|
||||
async index({ request, response }: HttpContext) {
|
||||
const userId = request.input('user_id')
|
||||
const isActive = request.input('is_active')
|
||||
|
||||
if (!userId) {
|
||||
return response.badRequest({
|
||||
error: 'user_id query parameter is required',
|
||||
})
|
||||
}
|
||||
|
||||
const query = Keyword.query().where('user_id', userId)
|
||||
|
||||
if (isActive !== undefined) {
|
||||
const activeValue = isActive === 'true' || isActive === true
|
||||
query.where('is_active', activeValue)
|
||||
}
|
||||
|
||||
const keywords = await query.orderBy('created_at', 'desc')
|
||||
|
||||
return response.ok(keywords)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new keyword
|
||||
* POST /api/keywords
|
||||
* Body: { user_id: number, keyword: string, case_sensitive?: boolean }
|
||||
*/
|
||||
async store({ request, response }: HttpContext) {
|
||||
const userId = request.input('user_id')
|
||||
const keyword = request.input('keyword')
|
||||
const caseSensitive = request.input('case_sensitive', false)
|
||||
|
||||
if (!userId || !keyword) {
|
||||
return response.badRequest({
|
||||
error: 'user_id and keyword are required',
|
||||
})
|
||||
}
|
||||
|
||||
if (typeof keyword !== 'string' || keyword.trim().length === 0) {
|
||||
return response.badRequest({
|
||||
error: 'keyword must be a non-empty string',
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const newKeyword = await Keyword.create({
|
||||
userId,
|
||||
keyword: keyword.trim(),
|
||||
caseSensitive,
|
||||
isActive: true,
|
||||
})
|
||||
|
||||
return response.created(newKeyword)
|
||||
} catch (error) {
|
||||
return response.internalServerError({
|
||||
error: 'Failed to create keyword',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a keyword by id
|
||||
* DELETE /api/keywords/:id
|
||||
*/
|
||||
async destroy({ params, response }: HttpContext) {
|
||||
const keywordId = params.id
|
||||
|
||||
if (!keywordId || isNaN(Number(keywordId))) {
|
||||
return response.badRequest({
|
||||
error: 'Invalid keyword id',
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const keyword = await Keyword.findOrFail(keywordId)
|
||||
await keyword.delete()
|
||||
|
||||
return response.noContent()
|
||||
} catch (error) {
|
||||
if (error.code === 'E_ROW_NOT_FOUND') {
|
||||
return response.notFound({
|
||||
error: 'Keyword not found',
|
||||
})
|
||||
}
|
||||
|
||||
return response.internalServerError({
|
||||
error: 'Failed to delete keyword',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import ParseLog from '#models/parse_log'
|
||||
|
||||
export default class ParseLogsController {
|
||||
/**
|
||||
* List parse logs with pagination, ordered by latest
|
||||
* GET /api/parse-logs?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 parseLogs = await ParseLog.query()
|
||||
.orderBy('started_at', 'desc')
|
||||
.paginate(pageNumber, limitNumber)
|
||||
|
||||
return response.ok(parseLogs.serialize())
|
||||
} catch (error) {
|
||||
return response.internalServerError({
|
||||
error: 'Failed to fetch parse logs',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user