bid-tool/auto-bid-server/src/modules/bids/services/bids.service.ts

942 lines
26 KiB
TypeScript

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,
isTimePassedByMinutes,
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<Bid>,
@InjectRepository(BidHistory)
readonly bidHistoriesRepo: Repository<BidHistory>,
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<Bid> | (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<Bid>(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);
let metadata = BidMetadata.DEFAULT_META_DATA(webBid);
if (data.metadata) {
metadata = metadata.map((item) => {
const reqData = data.metadata.find(
(i) => i?.key_name === item.key_name,
);
if (!reqData) return { ...item };
return {
...item,
value:
item.key_name === BidMetadata.MODE_KEY
? JSON.stringify(reqData.value)
: reqData.value <= 0
? item.value
: String(reqData.value),
};
});
}
const result = await this.bidsRepo.save({
...data,
model,
web_bid: webBid,
metadata,
});
await this.emitAllBidEvent();
const warnings = [];
if (!webBid.username || !webBid.password) {
// Add warning message
warnings.push(
`Account setup for ${webBid.origin_url} website is not yet complete.`,
);
}
if (!webBid.active) {
// Add warning message
warnings.push(
`${webBid.origin_url} is disabled. Please enable to continue.`,
);
}
if (warnings.length) {
// Send event warning
this.eventEmitter.emit(Event.SEND_WARNING, {
title: `System Warning: Abnormal Data Detected`,
messages: warnings,
});
}
// Send event success
this.eventEmitter.emit(Event.SEND_SUCCESS, {
title: 'Successfully Added Product',
messages: [`Successfully added product to bid list: ${data.url}`],
});
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 getBidForClientUpdate(id: Bid['id']) {
return await this.bidsRepo.findOne({
where: { id },
relations: { histories: true, web_bid: true, metadata: true },
order: {
histories: {
price: 'DESC',
},
},
});
}
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);
}
/**
* Workflow
* START
* |
* |--> Tìm bid theo id --> Không có? --> Throw 404
* |
* |--> Nếu chưa có thời gian bắt đầu --> Tính offset --> Gán start_time
* |
* |--> Nếu đã hết giờ:
* | |--> Nếu outbid --> Gán status 'out-bid'
* | |--> Else --> Gán status 'win-bid'
* |
* |--> Nếu chưa hết giờ:
* | |--> Nếu vượt giới hạn --> Gán status 'out-bid'
* |
* |--> Nếu close_time mới > cũ --> cập nhật
* |--> Nếu có model mới và chưa có model --> gán
* |
* |--> Gọi `save(...)` để lưu lại DB
* |--> Nếu có metadata --> gọi `upsert`
* |
* |--> Gửi sự kiện emitAllBidEvent
* |--> Nếu status là out-bid hoặc win-bid --> gửi notification
* |
* * |--> Trả response
* END
*/
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
// let bid = await this.bidsRepo.findOne({
// where: { id },
// relations: { histories: true, web_bid: true, metadata: true },
// order: {
// histories: {
// price: 'DESC',
// },
// },
// });
let bid = await this.getBidForClientUpdate(id);
// 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,
);
// update
await this.bidsRepo.update(
{ id },
{ start_bid_time: bid.start_bid_time },
);
}
// 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);
// update
await this.bidsRepo.update(
{ id },
{ close_time: bid.close_time, close_time_ts: bid.close_time_ts },
);
bid = await this.getBidForClientUpdate(id);
}
// 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);
}
// Send INFO
if (bid.current_price < result.current_price && bid.histories.length) {
this.eventEmitter.emit(Event.SEND_INFO, {
title: 'New Higher Bid Detected',
messages: [
`Another user just placed a higher bid of ${result.current_price} on <a href="${bid.url}">${bid.name}</a>.`,
],
});
}
// Send error when bidding fail
if (
isTimePassedByMinutes(result.start_bid_time, 1) &&
!bid.histories.length &&
!this.bidMetadatasService.isSandbox(bid.metadata)
) {
this.eventEmitter.emit(Event.SEND_ERROR, {
title: 'Bidding Error Detected',
messages: [
`An error occurred while placing a bid on <a href="${bid.url}">${bid.name}</a>. Please check the system.`,
],
});
}
// 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<Bid | null> {
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);
}
}