import { NotificationService } from '@/modules/notification/notification.service'; 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 * as fs from 'fs'; import { existsSync, readdirSync } from 'fs'; import * as _ from 'lodash'; import { v4 as uuid } from 'uuid'; 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, parseVideoFileName, subtractMinutes, } from 'src/ultils'; import { In, IsNull, Not, Repository } from 'typeorm'; import { ClientUpdateBidDto } from '../dto/bid/client-update-bid.dto'; import { ClientUpdateLoginStatusDto } from '../dto/bid/client-update-login-status.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 { BidMetadata } from '../entities/bid-metadata.entity'; import { Bid } from '../entities/bid.entity'; import { ImageCompressionPipe } from '../pipes/image-compression-pipe'; import { Constant } from '../utils/constant'; import { Event } from '../utils/events'; import { BidMetadatasService } from './bid-metadatas.service'; import { WebBidsService } from './web-bids.service'; @Injectable() export class BidsService { constructor( @InjectRepository(Bid) readonly bidsRepo: Repository, @InjectRepository(BidHistory) readonly bidHistoriesRepo: Repository, private readonly webBidsService: WebBidsService, private readonly eventEmitter: EventEmitter2, private readonly notificationService: NotificationService, private readonly bidMetadatasService: BidMetadatasService, ) {} async index(query: PaginateQuery) { const filterableColumns: { [key in Column | (string & {})]?: | (FilterOperator | FilterSuffix)[] | true; } = { id: true, model: [FilterOperator.ILIKE], lot_id: true, close_time: true, name: [FilterOperator.ILIKE], status: true, }; query.filter = AppResponse.processFilters(query.filter, filterableColumns); const data = await paginate(query, this.bidsRepo, { sortableColumns: [ 'id', 'close_time', 'close_time_ts', '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, metadata: 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, response?: (result: Bid) => any) { 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 metadata = BidMetadata.DEFAULT_META_DATA(webBid); const result = await this.bidsRepo.save({ ...data, model, web_bid: webBid, metadata, }); await this.emitAllBidEvent(); return AppResponse.toResponse( response ? response(result) : plainToClass(Bid, result), ); } async update(id: Bid['id'], { metadata = [], ...data }: UpdateBidDto) { const prev = await this.bidsRepo.findOne({ where: { id }, relations: { web_bid: true, metadata: true }, }); if (!prev) { throw new NotFoundException( AppResponse.toResponse(false, { message: 'Product not found', status_code: HttpStatus.NOT_FOUND, }), ); } if (metadata) { await this.bidMetadatasService.bidMetadataRepo.upsert( metadata.map((item) => { return { ...item, bid: { id }, value: JSON.stringify(item.value) }; }), ['id', 'bid', 'key_name'], ); } let result = null; if (!prev.close_time) { // Trường hợp chưa có close_time => update đơn giản result = await this.bidsRepo.update(id, { ...data, }); } else { // Trường hợp đã có close_time => kiểm tra arrival_offset_seconds có thay đổi không const arrival_offset_seconds = this.bidMetadatasService.getArrivalOffsetSecondsByMode( metadata as BidMetadata[], ); const prev_arrival_offset_seconds = this.bidMetadatasService.getArrivalOffsetSecondsByMode(prev.metadata); if (arrival_offset_seconds !== prev_arrival_offset_seconds) { // Nếu offset thay đổi thì cần cập nhật lại start_bid_time const start_bid_time = arrival_offset_seconds ? subtractMinutes(prev.close_time, arrival_offset_seconds / 60) : prev.start_bid_time; result = await this.bidsRepo.update(id, { ...data, start_bid_time, }); } else { result = await this.bidsRepo.update(id, { ...data, }); } } 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 }, relations: { web_bid: true }, }); 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, metadata, ...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, metadata: 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 && data?.lot_id) { // Tách lấy arrival_offset_seconds trong product nếu không có lấy trong web bid. const arrival_offset_seconds = this.bidMetadatasService.getArrivalOffsetSecondsByMode(bid.metadata) || bid.web_bid.arrival_offset_seconds; // 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, arrival_offset_seconds / 60, ); } // 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; bid.close_time_ts = new Date(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 || 0, bid.current_price), updated_at: new Date(), // Cập nhật timestamp }); if (metadata) { await this.bidMetadatasService.upsert(metadata, bid); } // 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 }, relations: { web_bid: true }, }); 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 }, relations: { web_bid: true }, }); 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, }), ); // update time snapshot for API BID if (type === 'API_BID') { this.webBidsService.webBidRepo.update(id, { snapshot_at: new Date() }); } this.eventEmitter.emit(`working`, { status: 're-update', id, type, filename: data.filename, }); return AppResponse.toResponse(true); } async uploadRecord(id: Bid['id'], video: Express.Multer.File) { if (!video) { throw new BadRequestException( AppResponse.toResponse(null, { message: 'File or Url is required' }), ); } const data: { filename: string } | undefined = await new ImageCompressionPipe( `${Constant.RECORD_FOLDER}`, {}, { unique_image_folder: false, unique_name: false, }, ).transform(video); if (!data) throw new BadRequestException( AppResponse.toResponse(null, { message: "Can't create media", status_code: HttpStatus.BAD_REQUEST, }), ); 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 getRecord(name: string, res: Response) { const rootDir = process.cwd(); const filePath = join( rootDir, `${Constant.MEDIA_PATH}/${Constant.RECORD_FOLDER}/${name}`, ); if (!existsSync(filePath)) { throw new NotFoundException( AppResponse.toResponse(null, { message: 'Folder not found', status_code: HttpStatus.NOT_FOUND, }), ); } const stat = fs.statSync(filePath); const fileSize = stat.size; res.writeHead(200, { 'Content-Type': 'video/mp4', 'Content-Length': fileSize, }); const readStream = fs.createReadStream(filePath); readStream.pipe(res); } async getRecords(id: Bid['id']) { const rootDir = process.cwd(); const folderPath = join( rootDir, `${Constant.MEDIA_PATH}/${Constant.RECORD_FOLDER}`, ); if (!existsSync(folderPath)) { throw new NotFoundException( AppResponse.toResponse(null, { message: 'Folder not found', status_code: HttpStatus.NOT_FOUND, }), ); } const files = await fs.promises.readdir(folderPath); const data = files .map((item) => { return parseVideoFileName(item); }) .filter((i) => i.bid_id == id); const sorted = _.orderBy(data, ['timestamp'], ['desc']); return AppResponse.toResponse(sorted); } async deleteRecord(name: string) { const rootDir = process.cwd(); const filePath = join( rootDir, `${Constant.MEDIA_PATH}/${Constant.RECORD_FOLDER}/${name}`, ); if (!existsSync(filePath)) { throw new NotFoundException( AppResponse.toResponse(null, { message: 'File not found', status_code: HttpStatus.NOT_FOUND, }), ); } await fs.promises.unlink(filePath); return AppResponse.toResponse(true); } 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); } async emitLoginStatus(data: ClientUpdateLoginStatusDto) { this.eventEmitter.emit(Event.statusLogin(data.data), data); return AppResponse.toResponse(true); } async getNextBid(): Promise { const all = await this.bidsRepo.find({ where: { status: 'biding', close_time: Not(IsNull()) }, relations: { web_bid: true }, }); const now = Date.now(); let nextBid = null; let minDiff = Infinity; for (const bid of all) { const time = Date.parse(bid.close_time); if (!isNaN(time) && time >= now) { const diff = time - now; if (diff < minDiff) { minDiff = diff; nextBid = bid; } } } return nextBid; } async getBidByModel(model: string) { const bid = await this.bidsRepo.findOne({ where: { model }, relations: { metadata: true, web_bid: true }, select: { web_bid: { arrival_offset_seconds: true, early_tracking_seconds: true }, }, }); if (!bid) return AppResponse.toResponse(null, { status_code: HttpStatus.NOT_FOUND, }); return AppResponse.toResponse(plainToClass(Bid, bid)); } async hookAction( { id, type }: { id: Bid['id']; type: 'action' | 'api' }, data: any, ) { const bid = await this.bidsRepo.findOne({ where: { id }, relations: { metadata: true }, }); if (!bid) throw new NotFoundException( AppResponse.toResponse(null, { message: 'Not foud bid' }), ); if (type === 'api') { const result = JSON.parse(data?.data || {}); result['timestamp'] = new Date().getTime(); result['uuid'] = uuid(); const prevDemoResponse = this.bidMetadatasService.getDemoResponse( bid.metadata, ); console.log({ prevDemoResponse, result }); await this.bidMetadatasService.upsert( { [BidMetadata.DEMO_RESPONSE]: JSON.stringify([result, ...(prevDemoResponse || [])]) || JSON.stringify([]), }, bid, ); } else { const recordUrl = data?.record_url || ''; await this.bidMetadatasService.upsert( { [BidMetadata.LATEST_RECROD_LINK]: JSON.stringify(recordUrl), }, bid, ); } const latestBidData = await this.bidsRepo.findOne({ where: { id }, relations: { metadata: true }, }); this.eventEmitter.emit(Event.BID_DEMO, { bid: latestBidData, type }); return AppResponse.toResponse(data); } }