251123:0200 T6.1 to DO
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
IsString,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsEnum,
|
||||
IsNotEmpty,
|
||||
IsUrl,
|
||||
} from 'class-validator';
|
||||
import { NotificationType } from '../entities/notification.entity';
|
||||
|
||||
export class CreateNotificationDto {
|
||||
@IsInt()
|
||||
@IsNotEmpty()
|
||||
userId!: number; // ผู้รับ
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
title!: string; // หัวข้อ
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
message!: string; // ข้อความ
|
||||
|
||||
@IsEnum(NotificationType)
|
||||
@IsNotEmpty()
|
||||
type!: NotificationType; // ประเภท: EMAIL, LINE, SYSTEM
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
entityType?: string; // e.g., 'rfa', 'correspondence'
|
||||
|
||||
@IsInt()
|
||||
@IsOptional()
|
||||
entityId?: number; // e.g., rfa_id
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
link?: string; // Link ไปยังหน้าเว็บ (Frontend)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { IsInt, IsOptional, IsBoolean } from 'class-validator';
|
||||
import { Type, Transform } from 'class-transformer';
|
||||
|
||||
export class SearchNotificationDto {
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Type(() => Number)
|
||||
page: number = 1;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Type(() => Number)
|
||||
limit: number = 20;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@Transform(({ value }) => {
|
||||
if (value === 'true') return true;
|
||||
if (value === 'false') return false;
|
||||
return value;
|
||||
})
|
||||
isRead?: boolean; // กรอง: อ่านแล้ว/ยังไม่อ่าน
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../user/entities/user.entity';
|
||||
|
||||
export enum NotificationType {
|
||||
EMAIL = 'EMAIL',
|
||||
LINE = 'LINE',
|
||||
SYSTEM = 'SYSTEM',
|
||||
}
|
||||
|
||||
@Entity('notifications')
|
||||
export class Notification {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number;
|
||||
|
||||
@Column({ name: 'user_id' })
|
||||
userId!: number;
|
||||
|
||||
@Column({ length: 255 })
|
||||
title!: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
message!: string;
|
||||
|
||||
@Column({
|
||||
name: 'notification_type',
|
||||
type: 'enum',
|
||||
enum: NotificationType,
|
||||
})
|
||||
notificationType!: NotificationType;
|
||||
|
||||
@Column({ name: 'is_read', default: false })
|
||||
isRead!: boolean;
|
||||
|
||||
@Column({ name: 'entity_type', length: 50, nullable: true })
|
||||
entityType?: string; // e.g., 'rfa', 'circulation', 'correspondence'
|
||||
|
||||
@Column({ name: 'entity_id', nullable: true })
|
||||
entityId?: number;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
|
||||
// --- Relations ---
|
||||
|
||||
@ManyToOne(() => User)
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
user!: User;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, LessThan } from 'typeorm';
|
||||
import { Notification } from './entities/notification.entity';
|
||||
|
||||
@Injectable()
|
||||
export class NotificationCleanupService {
|
||||
private readonly logger = new Logger(NotificationCleanupService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Notification)
|
||||
private notificationRepo: Repository<Notification>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* ลบแจ้งเตือนที่ "อ่านแล้ว" และเก่ากว่า 30 วัน
|
||||
* รันทุกวันเวลาเที่ยงคืน
|
||||
*/
|
||||
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
|
||||
async handleCleanup() {
|
||||
this.logger.log('Running notification cleanup...');
|
||||
|
||||
const daysAgo = 30;
|
||||
const dateThreshold = new Date();
|
||||
dateThreshold.setDate(dateThreshold.getDate() - daysAgo);
|
||||
|
||||
try {
|
||||
const result = await this.notificationRepo.delete({
|
||||
isRead: true,
|
||||
createdAt: LessThan(dateThreshold),
|
||||
});
|
||||
|
||||
this.logger.log(`Deleted ${result.affected} old read notifications.`);
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to cleanup notifications', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Put,
|
||||
Param,
|
||||
UseGuards,
|
||||
ParseIntPipe,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { Notification } from './entities/notification.entity';
|
||||
import { NotificationService } from './notification.service';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { User } from '../user/entities/user.entity';
|
||||
import { SearchNotificationDto } from './dto/search-notification.dto'; // ✅ Import
|
||||
|
||||
@ApiTags('Notifications')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('notifications')
|
||||
export class NotificationController {
|
||||
constructor(
|
||||
private readonly notificationService: NotificationService,
|
||||
@InjectRepository(Notification)
|
||||
private notificationRepo: Repository<Notification>,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Get my notifications' })
|
||||
async getMyNotifications(
|
||||
@CurrentUser() user: User,
|
||||
@Query() searchDto: SearchNotificationDto, // ✅ ใช้ DTO แทน
|
||||
) {
|
||||
const { page = 1, limit = 20, isRead } = searchDto;
|
||||
|
||||
const where: any = { userId: user.user_id };
|
||||
|
||||
// เพิ่ม Filter isRead ถ้ามีการส่งมา
|
||||
if (isRead !== undefined) {
|
||||
where.isRead = isRead;
|
||||
}
|
||||
|
||||
const [items, total] = await this.notificationRepo.findAndCount({
|
||||
where,
|
||||
order: { createdAt: 'DESC' },
|
||||
take: limit,
|
||||
skip: (page - 1) * limit,
|
||||
});
|
||||
|
||||
const unreadCount = await this.notificationRepo.count({
|
||||
where: { userId: user.user_id, isRead: false },
|
||||
});
|
||||
|
||||
return { data: items, meta: { total, page, limit, unreadCount } };
|
||||
}
|
||||
|
||||
@Put(':id/read')
|
||||
@ApiOperation({ summary: 'Mark notification as read' })
|
||||
async markAsRead(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@CurrentUser() user: User,
|
||||
) {
|
||||
return this.notificationService.markAsRead(id, user.user_id);
|
||||
}
|
||||
|
||||
@Put('read-all')
|
||||
@ApiOperation({ summary: 'Mark all as read' })
|
||||
async markAllAsRead(@CurrentUser() user: User) {
|
||||
return this.notificationService.markAllAsRead(user.user_id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
WebSocketGateway,
|
||||
WebSocketServer,
|
||||
OnGatewayConnection,
|
||||
OnGatewayDisconnect,
|
||||
} from '@nestjs/websockets';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
@WebSocketGateway({
|
||||
cors: {
|
||||
origin: '*',
|
||||
},
|
||||
namespace: 'notifications',
|
||||
})
|
||||
export class NotificationGateway
|
||||
implements OnGatewayConnection, OnGatewayDisconnect
|
||||
{
|
||||
@WebSocketServer()
|
||||
server!: Server; // ✅ FIX: เติม ! (Definite Assignment Assertion)
|
||||
|
||||
private readonly logger = new Logger(NotificationGateway.name);
|
||||
|
||||
handleConnection(client: Socket) {
|
||||
this.logger.log(`Client connected: ${client.id}`);
|
||||
}
|
||||
|
||||
handleDisconnect(client: Socket) {
|
||||
this.logger.log(`Client disconnected: ${client.id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* ส่งแจ้งเตือนไปหา User แบบ Real-time
|
||||
*/
|
||||
sendToUser(userId: number, payload: any) {
|
||||
this.server.to(`user_${userId}`).emit('new_notification', payload);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { BullModule } from '@nestjs/bullmq';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { ScheduleModule } from '@nestjs/schedule'; // ✅ New
|
||||
|
||||
import { Notification } from './entities/notification.entity';
|
||||
import { User } from '../user/entities/user.entity';
|
||||
import { UserPreference } from '../user/entities/user-preference.entity';
|
||||
|
||||
import { NotificationService } from './notification.service';
|
||||
import { NotificationController } from './notification.controller';
|
||||
import { NotificationProcessor } from './notification.processor';
|
||||
import { NotificationGateway } from './notification.gateway'; // ✅ New
|
||||
import { NotificationCleanupService } from './notification-cleanup.service'; // ✅ New
|
||||
import { UserModule } from '../user/user.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Notification, User, UserPreference]),
|
||||
BullModule.registerQueue({
|
||||
name: 'notifications',
|
||||
}),
|
||||
ScheduleModule.forRoot(), // ✅ New (ถ้ายังไม่ได้ import ใน AppModule)
|
||||
ConfigModule,
|
||||
UserModule,
|
||||
],
|
||||
controllers: [NotificationController],
|
||||
providers: [
|
||||
NotificationService,
|
||||
NotificationProcessor,
|
||||
NotificationGateway, // ✅ New
|
||||
NotificationCleanupService, // ✅ New
|
||||
],
|
||||
exports: [NotificationService],
|
||||
})
|
||||
export class NotificationModule {}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Processor, WorkerHost } from '@nestjs/bullmq';
|
||||
import { Job } from 'bullmq';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as nodemailer from 'nodemailer';
|
||||
import axios from 'axios';
|
||||
|
||||
import { UserService } from '../user/user.service';
|
||||
|
||||
@Processor('notifications')
|
||||
export class NotificationProcessor extends WorkerHost {
|
||||
private readonly logger = new Logger(NotificationProcessor.name);
|
||||
private mailerTransport: nodemailer.Transporter;
|
||||
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
private userService: UserService,
|
||||
) {
|
||||
super();
|
||||
// Setup Nodemailer
|
||||
this.mailerTransport = nodemailer.createTransport({
|
||||
host: this.configService.get('SMTP_HOST'),
|
||||
port: this.configService.get('SMTP_PORT'),
|
||||
secure: this.configService.get('SMTP_SECURE') === 'true',
|
||||
auth: {
|
||||
user: this.configService.get('SMTP_USER'),
|
||||
pass: this.configService.get('SMTP_PASS'),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async process(job: Job<any, any, string>): Promise<any> {
|
||||
this.logger.debug(`Processing job ${job.name} for user ${job.data.userId}`);
|
||||
|
||||
switch (job.name) {
|
||||
case 'send-email':
|
||||
return this.handleSendEmail(job.data);
|
||||
case 'send-line':
|
||||
return this.handleSendLine(job.data);
|
||||
default:
|
||||
throw new Error(`Unknown job name: ${job.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleSendEmail(data: any) {
|
||||
const user = await this.userService.findOne(data.userId);
|
||||
if (!user || !user.email) {
|
||||
this.logger.warn(`User ${data.userId} has no email`);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.mailerTransport.sendMail({
|
||||
from: '"LCBP3 DMS" <no-reply@np-dms.work>',
|
||||
to: user.email,
|
||||
subject: `[DMS] ${data.title}`,
|
||||
html: `
|
||||
<h3>${data.title}</h3>
|
||||
<p>${data.message}</p>
|
||||
<br/>
|
||||
<a href="${data.link}">คลิกเพื่อดูรายละเอียด</a>
|
||||
`,
|
||||
});
|
||||
this.logger.log(`Email sent to ${user.email}`);
|
||||
}
|
||||
|
||||
private async handleSendLine(data: any) {
|
||||
const user = await this.userService.findOne(data.userId);
|
||||
// ตรวจสอบว่า User มี Line ID หรือไม่ (หรือใช้ Group Token ถ้าเป็นระบบรวม)
|
||||
// ในที่นี้สมมติว่าเรายิงเข้า n8n webhook เพื่อจัดการต่อ
|
||||
const n8nWebhookUrl = this.configService.get('N8N_LINE_WEBHOOK_URL');
|
||||
|
||||
if (!n8nWebhookUrl) {
|
||||
this.logger.warn('N8N_LINE_WEBHOOK_URL not configured');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await axios.post(n8nWebhookUrl, {
|
||||
userId: user.user_id, // หรือ user.lineId ถ้ามี
|
||||
message: `${data.title}\n${data.message}`,
|
||||
link: data.link,
|
||||
});
|
||||
this.logger.log(`Line notification sent via n8n for user ${data.userId}`);
|
||||
} catch (error: any) {
|
||||
throw new Error(`Failed to send Line notification: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { Queue } from 'bullmq';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
// Entities
|
||||
import { Notification, NotificationType } from './entities/notification.entity';
|
||||
import { User } from '../user/entities/user.entity';
|
||||
import { UserPreference } from '../user/entities/user-preference.entity';
|
||||
|
||||
// Gateway
|
||||
import { NotificationGateway } from './notification.gateway';
|
||||
|
||||
// Interfaces
|
||||
export interface NotificationJobData {
|
||||
userId: number;
|
||||
title: string;
|
||||
message: string;
|
||||
type: 'EMAIL' | 'LINE' | 'SYSTEM';
|
||||
entityType?: string; // e.g., 'rfa'
|
||||
entityId?: number; // e.g., rfa_id
|
||||
link?: string; // Deep link to frontend
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class NotificationService {
|
||||
private readonly logger = new Logger(NotificationService.name);
|
||||
|
||||
constructor(
|
||||
@InjectQueue('notifications') private notificationQueue: Queue,
|
||||
@InjectRepository(Notification)
|
||||
private notificationRepo: Repository<Notification>,
|
||||
@InjectRepository(User)
|
||||
private userRepo: Repository<User>,
|
||||
@InjectRepository(UserPreference)
|
||||
private userPrefRepo: Repository<UserPreference>,
|
||||
private notificationGateway: NotificationGateway,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* ส่งการแจ้งเตือน (Trigger Notification)
|
||||
* ฟังก์ชันนี้จะตรวจสอบ Preference ของผู้ใช้ และ Push ลง Queue
|
||||
*/
|
||||
async send(data: NotificationJobData) {
|
||||
try {
|
||||
// 1. สร้าง Entity Instance (ยังไม่บันทึกลง DB)
|
||||
// ใช้ Enum NotificationType.SYSTEM เพื่อให้ตรงกับ Type Definition
|
||||
const notification = this.notificationRepo.create({
|
||||
userId: data.userId,
|
||||
title: data.title,
|
||||
message: data.message,
|
||||
notificationType: NotificationType.SYSTEM,
|
||||
entityType: data.entityType,
|
||||
entityId: data.entityId,
|
||||
isRead: false,
|
||||
});
|
||||
|
||||
// 2. บันทึกลง DB (ต้อง await เพื่อให้ได้ ID กลับมา)
|
||||
const savedNotification = await this.notificationRepo.save(notification);
|
||||
|
||||
// 3. Real-time Push (ผ่าน WebSocket Gateway)
|
||||
// ส่งข้อมูลที่ save แล้ว (มี ID) ไปให้ Frontend
|
||||
this.notificationGateway.sendToUser(data.userId, savedNotification);
|
||||
|
||||
// 4. ตรวจสอบ User Preferences เพื่อส่งช่องทางอื่น (Email/Line)
|
||||
const userPref = await this.userPrefRepo.findOne({
|
||||
where: { userId: data.userId },
|
||||
});
|
||||
|
||||
// Default: ถ้าไม่มี Pref ให้ส่ง Email/Line เป็นค่าเริ่มต้น (true)
|
||||
const shouldSendEmail = userPref ? userPref.notifyEmail : true;
|
||||
const shouldSendLine = userPref ? userPref.notifyLine : true;
|
||||
|
||||
const jobs = [];
|
||||
|
||||
// 5. Push to Queue (Email)
|
||||
// เงื่อนไข: User เปิดรับ Email และ Type ของ Noti นี้ไม่ใช่ LINE-only
|
||||
if (shouldSendEmail && data.type !== 'LINE') {
|
||||
jobs.push({
|
||||
name: 'send-email',
|
||||
data: { ...data, notificationId: savedNotification.id },
|
||||
opts: {
|
||||
attempts: 3, // ลองใหม่ 3 ครั้งถ้าล่ม
|
||||
backoff: {
|
||||
type: 'exponential',
|
||||
delay: 5000, // รอ 5 วิ, 10 วิ, 20 วิ...
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 6. Push to Queue (Line)
|
||||
// เงื่อนไข: User เปิดรับ Line และ Type ของ Noti นี้ไม่ใช่ EMAIL-only
|
||||
if (shouldSendLine && data.type !== 'EMAIL') {
|
||||
jobs.push({
|
||||
name: 'send-line',
|
||||
data: { ...data, notificationId: savedNotification.id },
|
||||
opts: {
|
||||
attempts: 3,
|
||||
backoff: { type: 'fixed', delay: 3000 },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (jobs.length > 0) {
|
||||
await this.notificationQueue.addBulk(jobs);
|
||||
}
|
||||
|
||||
this.logger.log(`Notification queued for user ${data.userId}`);
|
||||
} catch (error) {
|
||||
// Cast Error เพื่อให้ TypeScript ไม่ฟ้องใน Strict Mode
|
||||
this.logger.error(
|
||||
`Failed to queue notification: ${(error as Error).message}`,
|
||||
);
|
||||
// Note: ไม่ Throw error เพื่อไม่ให้กระทบ Flow หลัก (Resilience Pattern)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* อ่านแจ้งเตือน (Mark as Read)
|
||||
*/
|
||||
async markAsRead(id: number, userId: number) {
|
||||
await this.notificationRepo.update({ id, userId }, { isRead: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* อ่านทั้งหมด (Mark All as Read)
|
||||
*/
|
||||
async markAllAsRead(userId: number) {
|
||||
await this.notificationRepo.update(
|
||||
{ userId, isRead: false },
|
||||
{ isRead: true },
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user