import { BadRequestException, HttpStatus, Injectable, NotFoundException, } from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { InjectRepository } from '@nestjs/typeorm'; import { plainToClass } from 'class-transformer'; import { Response } from 'express'; import { existsSync, readdirSync } from 'fs'; import { FilterOperator, FilterSuffix, paginate, PaginateQuery, } from 'nestjs-paginate'; import { Column } from 'nestjs-paginate/lib/helper'; import { join } from 'path'; import AppResponse from 'src/response/app-response'; import { extractModelId, isTimeReached, subtractMinutes } from 'src/ultils'; import { In, Repository } from 'typeorm'; import { ClientUpdateBidDto } from '../dto/bid/client-update-bid.dto'; import { CreateBidDto } from '../dto/bid/create-bid.dto'; import { UpdateBidDto } from '../dto/bid/update-bid.dto'; import { UpdateStatusByPriceDto } from '../dto/bid/update-status-by-price.dto'; import { BidHistory } from '../entities/bid-history.entity'; import { Bid } from '../entities/bid.entity'; import { ImageCompressionPipe } from '../pipes/image-compression-pipe'; import { Constant } from '../utils/constant'; import { WebBidsService } from './web-bids.service'; import { NotificationService } from '@/modules/notification/notification.service'; import { Event } from '../utils/events'; import _ from 'lodash'; @Injectable() export class BidsService { constructor( @InjectRepository(Bid) readonly bidsRepo: Repository, @InjectRepository(BidHistory) readonly bidHistoriesRepo: Repository, private readonly webBidsService: WebBidsService, private eventEmitter: EventEmitter2, private notificationService: NotificationService, ) {} async index(query: PaginateQuery) { const filterableColumns: { [key in Column | (string & {})]?: | (FilterOperator | FilterSuffix)[] | true; } = { id: true, model: true, lot_id: true, close_time: true, name: true, }; query.filter = AppResponse.processFilters(query.filter, filterableColumns); const data = await paginate(query, this.bidsRepo, { sortableColumns: [ 'id', 'close_time', 'first_bid', 'model', 'lot_id', 'max_price', 'status', 'name', ], searchableColumns: ['id', 'status', 'model', 'lot_id', 'name'], defaultLimit: 15, filterableColumns, defaultSortBy: [['id', 'DESC']], maxLimit: 100, relations: { histories: true, web_bid: true, }, }); return AppResponse.toPagination(data, true, Bid); } async clientIndex() { const result = await this.bidsRepo.find({ relations: { histories: true }, where: { status: 'biding' }, }); return AppResponse.toResponse(result); } async emitAllBidEvent() { await this.webBidsService.emitAllBidEvent(); } async create(data: CreateBidDto) { const model = extractModelId(data.url); const prev = await this.bidsRepo.findOne({ where: { model: model } }); if (prev) { throw new BadRequestException( AppResponse.toResponse(null, { message: 'Product is already exits' }), ); } const webBid = await this.webBidsService.createByUrl(data.url); const result = await this.bidsRepo.save({ ...data, model, web_bid: webBid, }); await this.emitAllBidEvent(); return AppResponse.toResponse(plainToClass(Bid, result)); } async update(id: Bid['id'], data: UpdateBidDto) { const prev = await this.bidsRepo.findOne({ where: { id } }); if (!prev) { throw new NotFoundException( AppResponse.toResponse(false, { message: 'Product not found', status_code: HttpStatus.NOT_FOUND, }), ); } const result = await this.bidsRepo.update(id, { ...data, // status: // prev.max_price + prev.plus_price > data.max_price // ? 'out-bid' // : prev.status, }); if (!result) throw new BadRequestException(false); await this.emitAllBidEvent(); return AppResponse.toResponse(true); } async toggle(id: Bid['id']) { const bid = await this.bidsRepo.findOne({ where: { id } }); if (!bid) { throw new NotFoundException( AppResponse.toResponse(false, { message: 'Product not found', status_code: HttpStatus.NOT_FOUND, }), ); } if (bid.status === 'biding') { await this.bidsRepo.update(id, { status: 'out-bid' }); this.emitAllBidEvent(); // send message event this.notificationService.emitBidStatus({ ...bid, status: 'out-bid' }); return AppResponse.toResponse(true); } const lastHistory = await this.bidHistoriesRepo.findOne({ where: { bid: { id: bid.id } }, order: { created_at: 'DESC', }, }); if (lastHistory && lastHistory.price + bid.plus_price > bid.max_price) { throw new BadRequestException( AppResponse.toResponse(false, { message: 'Price is out of Max Price' }), ); } if (bid.close_time && isTimeReached(bid.close_time)) { throw new BadRequestException( AppResponse.toResponse(false, { message: 'Product was closed' }), ); } await this.bidsRepo.update(id, { status: 'biding' }); // send message event this.notificationService.emitBidStatus({ ...bid, status: 'biding' }); this.emitAllBidEvent(); return AppResponse.toResponse(true); } async clientUpdate( id: Bid['id'], { close_time, model, ...data }: ClientUpdateBidDto, // Nhận dữ liệu cập nhật ) { // Tìm kiếm phiên đấu giá trong database theo id const bid = await this.bidsRepo.findOne({ where: { id }, relations: { histories: true, web_bid: true }, order: { histories: { price: 'DESC', }, }, }); // Nếu không tìm thấy phiên đấu giá, trả về lỗi 404 if (!bid) throw new NotFoundException( AppResponse.toResponse(null, { message: 'Not found bid', status_code: HttpStatus.NOT_FOUND, }), ); // Nếu phiên đấu giá chưa có thời gian bắt đầu và kết thúc if (!bid.close_time && !bid.start_bid_time) { // Thiết lập thời gian bắt đầu là 5 phút trước khi đóng // bid.start_bid_time = new Date().toUTCString(); bid.start_bid_time = subtractMinutes(close_time, 5); } // Kiểm tra nếu thời gian đóng bid đã đạt tới (tức phiên đấu giá đã kết thúc) if (bid.close_time && isTimeReached(bid.close_time)) { const bidHistoriesItem = bid.histories[0]; // Lấy lịch sử bid gần nhất (mới nhất) if ( !bidHistoriesItem || // Nếu giá cuối cùng không phải là giá của người dùng và giá hiện tại vượt quá mức người dùng đặt + bước giá (bidHistoriesItem.price !== data.current_price && data.current_price > bid.max_price + bid.plus_price) ) { bid.status = 'out-bid'; // Người dùng đã bị outbid khi đấu giá kết thúc } else { bid.status = 'win-bid'; // Người dùng là người thắng nếu không bị outbid } } else { // Nếu phiên đấu giá vẫn đang diễn ra và giá hiện tại vượt quá giới hạn đặt của người dùng if ( data.current_price > bid.max_price + bid.plus_price || (!bid.histories.length && data.reserve_price > bid.max_price + bid.plus_price) ) { bid.status = 'out-bid'; // Gán trạng thái là đã bị outbid } } // Cập nhật thời gian kết thúc đấu giá nếu `close_time` mới lớn hơn `close_time` cũ if ( close_time && new Date(close_time).getTime() > new Date(bid.close_time).getTime() ) { bid.close_time = close_time; } // Nếu chưa có `model` nhưng dữ liệu mới có model, thì cập nhật model if (model && !bid.model) { bid.model = model; } // Lưu cập nhật vào database const result = await this.bidsRepo.save({ ...bid, ...data, current_price: Math.max(data.current_price, bid.current_price), updated_at: new Date(), // Cập nhật timestamp }); // Phát sự kiện cập nhật toàn bộ danh sách đấu giá this.emitAllBidEvent(); // send event message // Nếu trạng thái của bid là 'out-bid', gửi thông báo if (['out-bid', 'win-bid'].includes(result.status)) { this.notificationService.emitBidStatus(result); } // Trả về kết quả cập nhật dưới dạng response chuẩn return AppResponse.toResponse(plainToClass(Bid, result)); } async outBid(id: Bid['id']) { const result = await this.bidsRepo.update(id, { status: 'out-bid' }); const bid = await this.bidsRepo.findOne({ where: { id } }); if (!result) throw new BadRequestException(AppResponse.toResponse(false)); await this.emitAllBidEvent(); // send message event this.notificationService.emitBidStatus(bid); return AppResponse.toResponse(true); } async delete(id: Bid['id']) { const bid = await this.bidsRepo.findOne({ where: { id } }); if (!bid) throw new NotFoundException( AppResponse.toResponse(false, { message: 'Bid is not found', status_code: HttpStatus.NOT_FOUND, }), ); await this.bidsRepo.delete({ id: bid.id }); if (bid.status === 'biding') { this.emitAllBidEvent(); } return AppResponse.toResponse(true, { message: 'Delete success !' }); } async deletes(ids: Bid['id'][]) { const result = await this.bidsRepo.delete({ id: In(ids), }); if (!result.affected) { throw new BadRequestException( AppResponse.toResponse(false, { message: 'No items have been deleted yet.', status_code: HttpStatus.BAD_REQUEST, }), ); } this.emitAllBidEvent(); return AppResponse.toResponse(true, { message: 'Delete success !' }); } async updateStatusByPrice(id: Bid['id'], data: UpdateStatusByPriceDto) { const bid = await this.bidsRepo.findOne({ where: { id } }); if (!bid) throw new NotFoundException( AppResponse.toResponse(false, { message: 'Bid is not found', status_code: HttpStatus.NOT_FOUND, }), ); if (!isTimeReached(bid.close_time)) { throw new BadRequestException( AppResponse.toResponse(false, { message: 'Product is opening' }), ); } const lastHistory = await this.bidHistoriesRepo.findOne({ where: { bid: { id: bid.id } }, order: { created_at: 'DESC' }, }); if (lastHistory && lastHistory.price === data.current_price) { if (bid.status !== 'win-bid') { await this.bidsRepo.update(bid.id, { status: 'win-bid' }); // send event message this.notificationService.emitBidStatus({ ...bid, status: 'win-bid', }); } } else { if (bid.status !== 'out-bid') { await this.bidsRepo.update(bid.id, { status: 'out-bid' }); // send event message this.notificationService.emitBidStatus({ ...bid, status: 'out-bid', }); } } this.emitAllBidEvent(); return AppResponse.toResponse(true); } async updateStatusWork( id: Bid['id'], type: string, image: Express.Multer.File, ) { if (!image) { throw new BadRequestException( AppResponse.toResponse(null, { message: 'File or Url is required' }), ); } const data: { filename: string } | undefined = await new ImageCompressionPipe( `${Constant.WORK_IMAGES_FOLDER}/${type.replaceAll('_', '-').toLocaleLowerCase()}/${id}`, {}, { unique_image_folder: true, unique_name: true, }, ).transform(image); if (!data) throw new BadRequestException( AppResponse.toResponse(null, { message: "Can't create media", status_code: HttpStatus.BAD_REQUEST, }), ); this.eventEmitter.emit(`working`, { status: 're-update', id, type, filename: data.filename, }); return AppResponse.toResponse(true); } async getStatusWorkingImage( id: Bid['id'], type: string, name: string, res: Response, ) { const rootDir = process.cwd(); const folderPath = join( rootDir, `${Constant.MEDIA_PATH}/${Constant.WORK_IMAGES_FOLDER}/${type}/${id}`, ); if (!existsSync(folderPath)) { throw new NotFoundException( AppResponse.toResponse(null, { message: 'Folder not found', status_code: HttpStatus.NOT_FOUND, }), ); } let filePath: string; if (name === Event.WORKING) { const files = readdirSync(folderPath).filter((file) => /\.(jpg|jpeg|png|webp)$/i.test(file), ); if (files.length === 0) { throw new NotFoundException( AppResponse.toResponse(null, { message: 'No images found', status_code: HttpStatus.NOT_FOUND, }), ); } // Lấy ảnh đầu tiên trong danh sách filePath = join(folderPath, files[0]); } else { // Nếu name khác 'working', lấy file cụ thể filePath = join(folderPath, `${name}`); if (!existsSync(filePath)) { throw new NotFoundException( AppResponse.toResponse(null, { message: 'Image not found', status_code: HttpStatus.NOT_FOUND, }), ); } } return res.sendFile(filePath); } async getImagesWorking(id: Bid['id'], type: string) { const rootDir = process.cwd(); const folderPath = join( rootDir, `${Constant.MEDIA_PATH}/${Constant.WORK_IMAGES_FOLDER}/${type}/${id}`, ); if (!existsSync(folderPath)) { throw new NotFoundException( AppResponse.toResponse(null, { message: 'Folder not found', status_code: HttpStatus.NOT_FOUND, }), ); } // Lấy danh sách file trong folderPath const files = readdirSync(folderPath); return AppResponse.toResponse(files); } }