528 lines
15 KiB
TypeScript
528 lines
15 KiB
TypeScript
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';
|
|
import { ClientUpdateLoginStatusDto } from '../dto/bid/client-update-login-status.dto';
|
|
|
|
@Injectable()
|
|
export class BidsService {
|
|
constructor(
|
|
@InjectRepository(Bid)
|
|
readonly bidsRepo: Repository<Bid>,
|
|
@InjectRepository(BidHistory)
|
|
readonly bidHistoriesRepo: Repository<BidHistory>,
|
|
private readonly webBidsService: WebBidsService,
|
|
private eventEmitter: EventEmitter2,
|
|
private notificationService: NotificationService,
|
|
) {}
|
|
|
|
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',
|
|
'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<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) {
|
|
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,
|
|
bid.web_bid.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;
|
|
}
|
|
|
|
// 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
|
|
});
|
|
|
|
// 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,
|
|
}),
|
|
);
|
|
|
|
// 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 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);
|
|
}
|
|
|
|
async emitLoginStatus(data: ClientUpdateLoginStatusDto) {
|
|
this.eventEmitter.emit(Event.statusLogin(data.data), data);
|
|
|
|
return AppResponse.toResponse(true);
|
|
}
|
|
}
|