notification

This commit is contained in:
nkhangg 2025-03-28 10:57:51 +07:00
parent 46979c6b9e
commit 88bf1cabb6
21 changed files with 407 additions and 22 deletions

View File

@ -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
}

View File

@ -0,0 +1 @@
{"createdAt":1743133400720}

View File

@ -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: [],

View File

@ -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 {}

View File

@ -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<string> {
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<string>('CHAT_ID'),
): Promise<any> {
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<boolean> {
try {
const text = this.formatBidMessage(bid);

View File

@ -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 {}

View File

@ -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',

View File

@ -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<Bid>,
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,

View File

@ -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<BidHistory>,
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();

View File

@ -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';

View File

@ -0,0 +1,3 @@
export const NAME_EVENTS = {
BID_STATUS: 'notify.bid-status',
};

View File

@ -0,0 +1 @@
export class CreateNotificationDto {}

View File

@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateNotificationDto } from './create-notification.dto';
export class UpdateNotificationDto extends PartialType(CreateNotificationDto) {}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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);
}
}

View File

@ -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>(NotificationController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});

View File

@ -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();
}
}

View File

@ -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 {}

View File

@ -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>(NotificationService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@ -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<Notification>,
) {}
async index(query: PaginateQuery) {
const filterableColumns: {
[key in Column<Bid> | (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<Notification>(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,
});
}
}