251123:0200 T6.1 to DO

This commit is contained in:
2025-11-23 02:23:38 +07:00
parent 17d9f172d4
commit 23006898d9
58 changed files with 3221 additions and 502 deletions
@@ -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 },
);
}
}