diff --git a/auto-bid-server/bot-data/group_-1002593407119.json b/auto-bid-server/bot-data/group_-1002593407119.json new file mode 100644 index 0000000..da03669 --- /dev/null +++ b/auto-bid-server/bot-data/group_-1002593407119.json @@ -0,0 +1,26 @@ +{ + "id": -1002593407119, + "title": "Bid histories dev", + "type": "supergroup", + "invite_link": "https://t.me/+CSBIA7mbyBhkM2Jl", + "permissions": { + "can_send_messages": true, + "can_send_media_messages": true, + "can_send_audios": true, + "can_send_documents": true, + "can_send_photos": true, + "can_send_videos": true, + "can_send_video_notes": true, + "can_send_voice_notes": true, + "can_send_polls": true, + "can_send_other_messages": true, + "can_add_web_page_previews": true, + "can_change_info": true, + "can_invite_users": true, + "can_pin_messages": true, + "can_manage_topics": true + }, + "join_to_send_messages": true, + "max_reaction_count": 11, + "accent_color_id": 2 +} \ No newline at end of file diff --git a/auto-bid-server/bot-data/metadata.json b/auto-bid-server/bot-data/metadata.json new file mode 100644 index 0000000..5695ff7 --- /dev/null +++ b/auto-bid-server/bot-data/metadata.json @@ -0,0 +1 @@ +{"createdAt":1743133400720} \ No newline at end of file diff --git a/auto-bid-server/src/app.module.ts b/auto-bid-server/src/app.module.ts index 253ceb7..5132fe1 100644 --- a/auto-bid-server/src/app.module.ts +++ b/auto-bid-server/src/app.module.ts @@ -13,6 +13,7 @@ import { } from './system/routes/exclude-route'; import { AuthorizationMiddleware } from './modules/admins/middlewares/authorization.middleware'; import { ClientAuthenticationMiddleware } from './modules/auth/middlewares/client-authentication.middleware'; +import { NotificationModule } from './modules/notification/notification.module'; @Module({ imports: [ @@ -22,6 +23,7 @@ import { ClientAuthenticationMiddleware } from './modules/auth/middlewares/clien AppValidatorsModule, AuthModule, AdminsModule, + NotificationModule, ], controllers: [], providers: [], diff --git a/auto-bid-server/src/modules/app-configs/app-configs.module.ts b/auto-bid-server/src/modules/app-configs/app-configs.module.ts index 36ba389..9cc8993 100644 --- a/auto-bid-server/src/modules/app-configs/app-configs.module.ts +++ b/auto-bid-server/src/modules/app-configs/app-configs.module.ts @@ -1,11 +1,16 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; +import { EventEmitterModule } from '@nestjs/event-emitter'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, }), + EventEmitterModule.forRoot({ + wildcard: true, + global: true, + }), ], }) export class AppConfigsModule {} diff --git a/auto-bid-server/src/modules/bids/apis/bot-telegram.api.ts b/auto-bid-server/src/modules/bids/apis/bot-telegram.api.ts index bffd5a7..bfa2538 100644 --- a/auto-bid-server/src/modules/bids/apis/bot-telegram.api.ts +++ b/auto-bid-server/src/modules/bids/apis/bot-telegram.api.ts @@ -5,6 +5,18 @@ import { escapeMarkdownV2 } from 'src/ultils'; import { Bid } from '../entities/bid.entity'; import * as dayjs from 'dayjs'; import { SendMessageHistoriesService } from '../services/send-message-histories.service'; +import { join } from 'path'; +import { Constant } from '../utils/constant'; +import { + existsSync, + mkdir, + mkdirSync, + readdirSync, + readFileSync, + statSync, + writeFile, + writeFileSync, +} from 'fs'; @Injectable() export class BotTelegramApi { @@ -64,6 +76,73 @@ export class BotTelegramApi { } } + async createFolderPath(): Promise { + const rootDir = process.cwd(); + const folderPath = join(rootDir, `${Constant.BOT_TELEGRAM_PATH}`); + + if (!existsSync(folderPath)) { + mkdirSync(folderPath, { recursive: true, mode: 0o777 }); + + // ✅ Lưu metadata lần đầu + const metadataPath = join(folderPath, 'metadata.json'); + writeFileSync( + metadataPath, + JSON.stringify({ createdAt: Date.now() }), + 'utf-8', + ); + } + + return folderPath; + } + + async getGroupInfo( + chatId: string = this.configService.get('CHAT_ID'), + ): Promise { + try { + const folderPath = await this.createFolderPath(); + const metadataPath = join(folderPath, 'metadata.json'); + const dataFilePath = join(folderPath, `group_${chatId}.json`); + + // 10 minute + const TIME_TO_REFRESH_DATA = 10; + + if (existsSync(metadataPath)) { + const metadata = JSON.parse(readFileSync(metadataPath, 'utf-8')); + const createdAt = metadata?.createdAt || 0; + const now = Date.now(); + const diffMinutes = (now - createdAt) / 60000; + + if (diffMinutes < TIME_TO_REFRESH_DATA && existsSync(dataFilePath)) { + return JSON.parse(readFileSync(dataFilePath, 'utf-8')); + } + } + + const url = `${this.apiUrl}/getChat`; + const { data } = await axios({ + url, + params: { chat_id: chatId }, + family: 4, + }); + + if (data?.ok) { + writeFileSync( + dataFilePath, + JSON.stringify(data.result, null, 2), + 'utf-8', + ); + writeFileSync( + metadataPath, + JSON.stringify({ createdAt: Date.now() }), + 'utf-8', + ); + + return data.result; + } + } catch (error) { + console.error(error || error.message); + } + } + async sendBidInfo(bid: Bid): Promise { try { const text = this.formatBidMessage(bid); diff --git a/auto-bid-server/src/modules/bids/bids.module.ts b/auto-bid-server/src/modules/bids/bids.module.ts index 86b83d9..a77d433 100644 --- a/auto-bid-server/src/modules/bids/bids.module.ts +++ b/auto-bid-server/src/modules/bids/bids.module.ts @@ -25,6 +25,7 @@ import { AdminSendMessageHistoriesController } from './controllers/admin/admin-s import { AuthModule } from '../auth/auth.module'; import { AdminsModule } from '../admins/admins.module'; import { AdminBidGateway } from './getways/admin-bid-getway'; +import { NotificationModule } from '../notification/notification.module'; @Module({ imports: [ @@ -35,11 +36,9 @@ import { AdminBidGateway } from './getways/admin-bid-getway'; WebBid, SendMessageHistory, ]), - EventEmitterModule.forRoot({ - wildcard: true, - }), // AuthModule, AdminsModule, + NotificationModule, ], controllers: [ BidsController, @@ -62,5 +61,6 @@ import { AdminBidGateway } from './getways/admin-bid-getway'; GraysApi, SendMessageHistoriesService, ], + exports: [BotTelegramApi], }) export class BidsModule {} diff --git a/auto-bid-server/src/modules/bids/getways/admin-bid-getway.ts b/auto-bid-server/src/modules/bids/getways/admin-bid-getway.ts index 2ea20a3..3ba98bc 100644 --- a/auto-bid-server/src/modules/bids/getways/admin-bid-getway.ts +++ b/auto-bid-server/src/modules/bids/getways/admin-bid-getway.ts @@ -1,20 +1,16 @@ +import { AdminsService } from '@/modules/admins/services/admins.service'; +import { getWayMiddleware } from '@/modules/auth/middlewares/get-way.middleware'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { JwtService } from '@nestjs/jwt'; import { + OnGatewayConnection, WebSocketGateway, WebSocketServer, - SubscribeMessage, - OnGatewayConnection, } from '@nestjs/websockets'; -import { Server, Socket } from 'socket.io'; -import { EventEmitter2 } from '@nestjs/event-emitter'; -import { BidsService } from '../services/bids.service'; -import { WebBidsService } from '../services/web-bids.service'; import { plainToClass } from 'class-transformer'; +import { Server, Socket } from 'socket.io'; import { WebBid } from '../entities/wed-bid.entity'; -import * as cookie from 'cookie'; -import { Constant } from '@/modules/auth/ultils/constant'; -import { getWayMiddleware } from '@/modules/auth/middlewares/get-way.middleware'; -import { AdminsService } from '@/modules/admins/services/admins.service'; -import { JwtService } from '@nestjs/jwt'; +import { WebBidsService } from '../services/web-bids.service'; @WebSocketGateway({ namespace: 'admin-bid-ws', diff --git a/auto-bid-server/src/modules/bids/services/bid-histories.service.ts b/auto-bid-server/src/modules/bids/services/bid-histories.service.ts index 7ce460c..f581bb8 100644 --- a/auto-bid-server/src/modules/bids/services/bid-histories.service.ts +++ b/auto-bid-server/src/modules/bids/services/bid-histories.service.ts @@ -13,6 +13,7 @@ import { Bid } from '../entities/bid.entity'; import { CreateBidHistoryDto } from '../dto/bid-history/create-bid-history.dto'; import { BotTelegramApi } from '../apis/bot-telegram.api'; import { SendMessageHistoriesService } from './send-message-histories.service'; +import { NotificationService } from '@/modules/notification/notification.service'; @Injectable() export class BidHistoriesService { @@ -23,6 +24,7 @@ export class BidHistoriesService { readonly bidsRepo: Repository, private readonly botTelegramApi: BotTelegramApi, readonly sendMessageHistoriesService: SendMessageHistoriesService, + private readonly notificationService: NotificationService, ) {} async index() { @@ -54,6 +56,9 @@ export class BidHistoriesService { if (price + bid.plus_price > bid.max_price) { this.bidsRepo.update(bid_id, { status: 'out-bid' }); + // send message event + this.notificationService.emitBidStatus({ ...bid, status: 'out-bid' }); + throw new BadRequestException( AppResponse.toResponse(null, { message: 'Price is more than Max price ' + bid.max_price, diff --git a/auto-bid-server/src/modules/bids/services/bids.service.ts b/auto-bid-server/src/modules/bids/services/bids.service.ts index e157581..99f5437 100644 --- a/auto-bid-server/src/modules/bids/services/bids.service.ts +++ b/auto-bid-server/src/modules/bids/services/bids.service.ts @@ -29,6 +29,7 @@ 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'; @Injectable() export class BidsService { @@ -39,6 +40,7 @@ export class BidsService { readonly bidHistoriesRepo: Repository, private readonly webBidsService: WebBidsService, private eventEmitter: EventEmitter2, + private notificationService: NotificationService, ) {} async index(query: PaginateQuery) { @@ -159,6 +161,9 @@ export class BidsService { this.emitAllBidEvent(); + // send message event + this.notificationService.emitBidStatus({ ...bid, status: 'out-bid' }); + return AppResponse.toResponse(true); } @@ -183,6 +188,9 @@ export class BidsService { await this.bidsRepo.update(id, { status: 'biding' }); + // send message event + this.notificationService.emitBidStatus({ ...bid, status: 'biding' }); + this.emitAllBidEvent(); return AppResponse.toResponse(true); } @@ -210,12 +218,6 @@ export class BidsService { data.current_price >= bid.max_price + bid.plus_price || (bid.close_time && isTimeReached(bid.close_time)) ) { - console.log({ - a: data.current_price >= bid.max_price + bid.plus_price, - b: bid.close_time && !close_time, - c: bid.close_time && isTimeReached(bid.close_time), - }); - bid.status = 'out-bid'; } @@ -234,16 +236,26 @@ export class BidsService { this.emitAllBidEvent(); + // send event message + if (result.status === 'out-bid') { + this.notificationService.emitBidStatus(result); + } + 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); } @@ -309,9 +321,25 @@ export class BidsService { }); if (lastHistory && lastHistory.price === data.current_price) { - await this.bidsRepo.update(bid.id, { status: 'win-bid' }); + 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 { - await this.bidsRepo.update(bid.id, { status: 'out-bid' }); + 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(); diff --git a/auto-bid-server/src/modules/bids/utils/constant.ts b/auto-bid-server/src/modules/bids/utils/constant.ts index bcf4dbc..0596e8c 100644 --- a/auto-bid-server/src/modules/bids/utils/constant.ts +++ b/auto-bid-server/src/modules/bids/utils/constant.ts @@ -1,5 +1,7 @@ export class Constant { public static MEDIA_PATH = 'public'; + public static BOT_TELEGRAM_PATH = 'bot-data'; + public static WORK_IMAGES_FOLDER = 'work-images'; public static TMP_FOLDER = 'tmp'; diff --git a/auto-bid-server/src/modules/notification/constants/index.ts b/auto-bid-server/src/modules/notification/constants/index.ts new file mode 100644 index 0000000..19a4b18 --- /dev/null +++ b/auto-bid-server/src/modules/notification/constants/index.ts @@ -0,0 +1,3 @@ +export const NAME_EVENTS = { + BID_STATUS: 'notify.bid-status', +}; diff --git a/auto-bid-server/src/modules/notification/dto/create-notification.dto.ts b/auto-bid-server/src/modules/notification/dto/create-notification.dto.ts new file mode 100644 index 0000000..98ca479 --- /dev/null +++ b/auto-bid-server/src/modules/notification/dto/create-notification.dto.ts @@ -0,0 +1 @@ +export class CreateNotificationDto {} diff --git a/auto-bid-server/src/modules/notification/dto/update-notification.dto.ts b/auto-bid-server/src/modules/notification/dto/update-notification.dto.ts new file mode 100644 index 0000000..df93517 --- /dev/null +++ b/auto-bid-server/src/modules/notification/dto/update-notification.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateNotificationDto } from './create-notification.dto'; + +export class UpdateNotificationDto extends PartialType(CreateNotificationDto) {} diff --git a/auto-bid-server/src/modules/notification/entities/notification.entity.ts b/auto-bid-server/src/modules/notification/entities/notification.entity.ts new file mode 100644 index 0000000..d9d8472 --- /dev/null +++ b/auto-bid-server/src/modules/notification/entities/notification.entity.ts @@ -0,0 +1,21 @@ +import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; +import { Timestamp } from './timestamp'; + +@Entity('notifications') +@Index(['message', 'raw_data']) +export class Notification extends Timestamp { + @PrimaryGeneratedColumn('increment') + id: number; + + @Column() + message: string; + + @Column({ type: 'varchar' }) + raw_data: string; + + @Column({ default: null, nullable: true }) + read_at: Date | null; + + @Column({ type: 'text' }) + send_to: string; +} diff --git a/auto-bid-server/src/modules/notification/entities/timestamp.ts b/auto-bid-server/src/modules/notification/entities/timestamp.ts new file mode 100644 index 0000000..0cf413c --- /dev/null +++ b/auto-bid-server/src/modules/notification/entities/timestamp.ts @@ -0,0 +1,8 @@ +import { CreateDateColumn, UpdateDateColumn } from 'typeorm'; +export abstract class Timestamp { + @CreateDateColumn({ type: 'timestamp', name: 'created_at' }) + created_at: Date; + + @UpdateDateColumn({ type: 'timestamp', name: 'updated_at' }) + updated_at: Date; +} diff --git a/auto-bid-server/src/modules/notification/listeners/admin-notification.listener.ts b/auto-bid-server/src/modules/notification/listeners/admin-notification.listener.ts new file mode 100644 index 0000000..5b4e007 --- /dev/null +++ b/auto-bid-server/src/modules/notification/listeners/admin-notification.listener.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { NAME_EVENTS } from '../constants'; +import { Bid } from '@/modules/bids/entities/bid.entity'; +import { Notification } from '../entities/notification.entity'; +import { BotTelegramApi } from '@/modules/bids/apis/bot-telegram.api'; + +@Injectable() +export class AdminNotificationListener { + constructor(private readonly botTelegramApi: BotTelegramApi) {} + + @OnEvent(NAME_EVENTS.BID_STATUS) + handleBidStatus({ + bid, + notification, + }: { + bid: Bid; + notification: Notification; + }) { + if (JSON.parse(notification.send_to).length <= 0) return; + + this.botTelegramApi.sendMessage(notification.message); + } +} diff --git a/auto-bid-server/src/modules/notification/notification.controller.spec.ts b/auto-bid-server/src/modules/notification/notification.controller.spec.ts new file mode 100644 index 0000000..04dfa50 --- /dev/null +++ b/auto-bid-server/src/modules/notification/notification.controller.spec.ts @@ -0,0 +1,20 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { NotificationController } from './notification.controller'; +import { NotificationService } from './notification.service'; + +describe('NotificationController', () => { + let controller: NotificationController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [NotificationController], + providers: [NotificationService], + }).compile(); + + controller = module.get(NotificationController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/auto-bid-server/src/modules/notification/notification.controller.ts b/auto-bid-server/src/modules/notification/notification.controller.ts new file mode 100644 index 0000000..e953096 --- /dev/null +++ b/auto-bid-server/src/modules/notification/notification.controller.ts @@ -0,0 +1,16 @@ +import { Controller, Get } from '@nestjs/common'; +import { BotTelegramApi } from '../bids/apis/bot-telegram.api'; +import { NotificationService } from './notification.service'; + +@Controller('admin/notifications') +export class NotificationController { + constructor( + private readonly notificationService: NotificationService, + private botTelegramApi: BotTelegramApi, + ) {} + + @Get('') + async test() { + return await this.botTelegramApi.getGroupInfo(); + } +} diff --git a/auto-bid-server/src/modules/notification/notification.module.ts b/auto-bid-server/src/modules/notification/notification.module.ts new file mode 100644 index 0000000..ddbbd19 --- /dev/null +++ b/auto-bid-server/src/modules/notification/notification.module.ts @@ -0,0 +1,18 @@ +import { forwardRef, Module } from '@nestjs/common'; +import { NotificationService } from './notification.service'; +import { NotificationController } from './notification.controller'; +import { BidsModule } from '../bids/bids.module'; +import { Notification } from './entities/notification.entity'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AdminNotificationListener } from './listeners/admin-notification.listener'; + +@Module({ + imports: [ + forwardRef(() => BidsModule), + TypeOrmModule.forFeature([Notification]), + ], + controllers: [NotificationController], + providers: [NotificationService, AdminNotificationListener], + exports: [NotificationService], +}) +export class NotificationModule {} diff --git a/auto-bid-server/src/modules/notification/notification.service.spec.ts b/auto-bid-server/src/modules/notification/notification.service.spec.ts new file mode 100644 index 0000000..65bd59d --- /dev/null +++ b/auto-bid-server/src/modules/notification/notification.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { NotificationService } from './notification.service'; + +describe('NotificationService', () => { + let service: NotificationService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [NotificationService], + }).compile(); + + service = module.get(NotificationService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/auto-bid-server/src/modules/notification/notification.service.ts b/auto-bid-server/src/modules/notification/notification.service.ts new file mode 100644 index 0000000..4fcaf71 --- /dev/null +++ b/auto-bid-server/src/modules/notification/notification.service.ts @@ -0,0 +1,108 @@ +import { Injectable } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { Bid } from '../bids/entities/bid.entity'; +import { NAME_EVENTS } from './constants'; +import { BotTelegramApi } from '../bids/apis/bot-telegram.api'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Notification } from './entities/notification.entity'; +import { Repository } from 'typeorm'; +import { isTimeReached } from '@/ultils'; +import { + FilterOperator, + FilterSuffix, + paginate, + PaginateQuery, +} from 'nestjs-paginate'; +import { Column } from 'nestjs-paginate/lib/helper'; +import AppResponse from '@/response/app-response'; + +@Injectable() +export class NotificationService { + constructor( + private eventEmitter: EventEmitter2, + private readonly botTelegramApi: BotTelegramApi, + @InjectRepository(Notification) + readonly notificationRepo: Repository, + ) {} + + async index(query: PaginateQuery) { + const filterableColumns: { + [key in Column | (string & {})]?: + | (FilterOperator | FilterSuffix)[] + | true; + } = { + id: true, + message: true, + }; + + query.filter = AppResponse.processFilters(query.filter, filterableColumns); + + const data = await paginate(query, this.notificationRepo, { + sortableColumns: ['id'], + searchableColumns: ['id'], + defaultLimit: 15, + filterableColumns, + defaultSortBy: [['id', 'DESC']], + maxLimit: 100, + }); + + return AppResponse.toPagination(data, true, Notification); + } + + getBidStatusMessage(bid: Bid): string | null { + switch (bid.status) { + case 'biding': + return !bid.name ? null : `✅ The item has been activated. ${bid.name}`; + + case 'out-bid': + if (isTimeReached(bid.close_time)) { + return `⏳ The auction for *${bid.name || 'this item'}* has ended.`; + } + + const itemName = `*${bid.name || 'the item'}*`; + + if ( + bid.max_price + bid.plus_price <= bid.current_price || + bid.reserve_price > bid.max_price + bid.plus_price + ) { + return `💰 The current bid for ${itemName} has exceeded your maximum bid.`; + } + + return `🛑 The auction for ${itemName} has been canceled.`; + + case 'win-bid': + return `🎉 Congratulations! You won the auction for ${itemName} at *${bid.current_price}*.`; + + default: + return '❓ Unknown auction status.'; + } + } + + async emitBidStatus(bid: Bid, sendTo: boolean = true) { + const groupData = await this.botTelegramApi.getGroupInfo(); + const sendToData = groupData && sendTo ? [groupData?.title || 'None'] : []; + + const message = this.getBidStatusMessage(bid); + if (!message) return; + + const notification = await this.notificationRepo.save({ + message, + raw_data: JSON.stringify({ + id: bid.id, + status: bid.status, + name: bid.name, + close_time: bid.close_time, + current_price: bid.current_price, + }), + send_to: JSON.stringify(sendToData), + }); + + this.eventEmitter.emit(NAME_EVENTS.BID_STATUS, { + bid: { + ...bid, + status: 'out-bid', + }, + notification, + }); + } +}