251125:0000 Phase 6 wait start dev Check

This commit is contained in:
2025-11-25 00:28:33 +07:00
parent 553c2d13ad
commit 582ecb5741
22 changed files with 3757 additions and 489 deletions

View File

@@ -1,5 +1,3 @@
// File: src/modules/master/dto/create-tag.dto.ts
import { IsString, IsNotEmpty, IsOptional } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
@@ -7,12 +5,9 @@ export class CreateTagDto {
@ApiProperty({ example: 'URGENT', description: 'ชื่อ Tag' })
@IsString()
@IsNotEmpty()
tag_name: string;
tag_name!: string; // เพิ่ม !
@ApiProperty({
example: 'เอกสารด่วนต้องดำเนินการทันที',
description: 'คำอธิบาย',
})
@ApiProperty({ example: 'คำอธิบาย', description: 'คำอธิบาย' })
@IsString()
@IsOptional()
description?: string;

View File

@@ -1,5 +1,3 @@
// File: src/modules/master/entities/tag.entity.ts
import {
Entity,
Column,
@@ -11,17 +9,17 @@ import {
@Entity('tags')
export class Tag {
@PrimaryGeneratedColumn()
id: number;
id!: number; // เพิ่ม !
@Column({ length: 100, unique: true, comment: 'ชื่อ Tag' })
tag_name: string;
@Column({ length: 100, unique: true })
tag_name!: string; // เพิ่ม !
@Column({ type: 'text', nullable: true, comment: 'คำอธิบายแท็ก' })
description: string;
@Column({ type: 'text', nullable: true })
description!: string; // เพิ่ม !
@CreateDateColumn()
created_at: Date;
created_at!: Date; // เพิ่ม !
@UpdateDateColumn()
updated_at: Date;
updated_at!: Date; // เพิ่ม !
}

View File

@@ -49,15 +49,15 @@ export class MasterService {
async findAllCorrespondenceTypes() {
return this.corrTypeRepo.find({
where: { is_active: true },
order: { sort_order: 'ASC' },
where: { isActive: true }, // ✅ แก้เป็น camelCase
order: { sortOrder: 'ASC' }, // ✅ แก้เป็น camelCase
});
}
async findAllCorrespondenceStatuses() {
return this.corrStatusRepo.find({
where: { is_active: true },
order: { sort_order: 'ASC' },
where: { isActive: true }, // ✅ แก้เป็น camelCase
order: { sortOrder: 'ASC' }, // ✅ แก้เป็น camelCase
});
}
@@ -67,22 +67,22 @@ export class MasterService {
async findAllRfaTypes() {
return this.rfaTypeRepo.find({
where: { is_active: true },
order: { sort_order: 'ASC' },
where: { isActive: true }, // ✅ แก้เป็น camelCase
order: { sortOrder: 'ASC' }, // ✅ แก้เป็น camelCase
});
}
async findAllRfaStatuses() {
return this.rfaStatusRepo.find({
where: { is_active: true },
order: { sort_order: 'ASC' },
where: { isActive: true }, // ✅ แก้เป็น camelCase
order: { sortOrder: 'ASC' }, // ✅ แก้เป็น camelCase
});
}
async findAllRfaApproveCodes() {
return this.rfaApproveRepo.find({
where: { is_active: true },
order: { sort_order: 'ASC' },
where: { isActive: true }, // ✅ แก้เป็น camelCase
order: { sortOrder: 'ASC' }, // ✅ แก้เป็น camelCase
});
}
@@ -92,8 +92,8 @@ export class MasterService {
async findAllCirculationStatuses() {
return this.circulationStatusRepo.find({
where: { is_active: true },
order: { sort_order: 'ASC' },
where: { isActive: true }, // ✅ แก้เป็น camelCase
order: { sortOrder: 'ASC' }, // ✅ แก้เป็น camelCase
});
}
@@ -101,9 +101,6 @@ export class MasterService {
// 🏷️ Tag Management (CRUD)
// =================================================================
/**
* ค้นหา Tag ทั้งหมด พร้อมรองรับการ Search และ Pagination
*/
async findAllTags(query?: SearchTagDto) {
const qb = this.tagRepo.createQueryBuilder('tag');
@@ -115,14 +112,12 @@ export class MasterService {
qb.orderBy('tag.tag_name', 'ASC');
// Pagination Logic
if (query?.page && query?.limit) {
const page = query.page;
const limit = query.limit;
qb.skip((page - 1) * limit).take(limit);
}
// ถ้ามีการแบ่งหน้า ให้ส่งคืนทั้งข้อมูลและจำนวนทั้งหมด (count)
if (query?.page && query?.limit) {
const [items, total] = await qb.getManyAndCount();
return {
@@ -153,7 +148,7 @@ export class MasterService {
}
async updateTag(id: number, dto: UpdateTagDto) {
const tag = await this.findOneTag(id); // Reuse findOne for check
const tag = await this.findOneTag(id);
Object.assign(tag, dto);
return this.tagRepo.save(tag);
}

View File

@@ -0,0 +1,16 @@
import { IsBoolean, IsOptional, IsString } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class SetMaintenanceDto {
@ApiProperty({ description: 'สถานะ Maintenance (true = เปิด, false = ปิด)' })
@IsBoolean()
enabled!: boolean; // ✅ เพิ่ม ! ตรงนี้
@ApiProperty({
description: 'เหตุผลที่ปิดปรับปรุง (แสดงให้ User เห็น)',
required: false,
})
@IsOptional()
@IsString()
reason?: string; // Optional (?) ไม่ต้องใส่ !
}

View File

@@ -0,0 +1,30 @@
import { Controller, Get, Post, Body, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { MonitoringService } from './monitoring.service';
import { SetMaintenanceDto } from './dto/set-maintenance.dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { BypassMaintenance } from '../../common/decorators/bypass-maintenance.decorator';
@ApiTags('System Monitoring')
@Controller('monitoring')
export class MonitoringController {
constructor(private readonly monitoringService: MonitoringService) {}
@Get('maintenance')
@ApiOperation({ summary: 'Check maintenance status (Public)' })
@BypassMaintenance() // API นี้ต้องเรียกได้แม้ระบบปิดอยู่
getMaintenanceStatus() {
return this.monitoringService.getMaintenanceStatus();
}
@Post('maintenance')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@RequirePermission('system.manage_all') // เฉพาะ Superadmin เท่านั้น
@BypassMaintenance() // Admin ต้องยิงเปิด/ปิดได้แม้ระบบจะปิดอยู่
@ApiOperation({ summary: 'Toggle Maintenance Mode (Admin Only)' })
setMaintenanceMode(@Body() dto: SetMaintenanceDto) {
return this.monitoringService.setMaintenanceMode(dto);
}
}

View File

@@ -1,23 +1,34 @@
// File: src/modules/monitoring/monitoring.module.ts
import { Global, Module } from '@nestjs/common';
import { TerminusModule } from '@nestjs/terminus';
import { HttpModule } from '@nestjs/axios';
import { APP_INTERCEPTOR } from '@nestjs/core';
// Existing Components
import { HealthController } from './controllers/health.controller';
import { MetricsService } from './services/metrics.service';
import { PerformanceInterceptor } from '../../common/interceptors/performance.interceptor';
@Global() // ทำให้ Module นี้ใช้งานได้ทั่วทั้ง App โดยไม่ต้อง Import ซ้ำ
// [NEW] Maintenance Mode Components
import { MonitoringController } from './monitoring.controller';
import { MonitoringService } from './monitoring.service';
@Global() // Module นี้เป็น Global (ดีแล้วครับ)
@Module({
imports: [TerminusModule, HttpModule],
controllers: [HealthController],
controllers: [
HealthController, // ✅ ของเดิม: /health
MonitoringController, // ✅ ของใหม่: /monitoring/maintenance
],
providers: [
MetricsService,
MetricsService, // ✅ ของเดิม
MonitoringService, // ✅ ของใหม่ (Logic เปิด/ปิด Maintenance)
{
provide: APP_INTERCEPTOR, // Register Global Interceptor
useClass: PerformanceInterceptor,
provide: APP_INTERCEPTOR,
useClass: PerformanceInterceptor, // ✅ ของเดิม (จับเวลา Response Time)
},
],
exports: [MetricsService],
exports: [MetricsService, MonitoringService],
})
export class MonitoringModule {}

View File

@@ -0,0 +1,44 @@
// File: src/modules/monitoring/monitoring.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { InjectRedis } from '@nestjs-modules/ioredis';
import Redis from 'ioredis';
import { SetMaintenanceDto } from './dto/set-maintenance.dto';
@Injectable()
export class MonitoringService {
private readonly logger = new Logger(MonitoringService.name);
private readonly MAINTENANCE_KEY = 'system:maintenance_mode';
constructor(@InjectRedis() private readonly redis: Redis) {}
/**
* ตรวจสอบสถานะปัจจุบัน
*/
async getMaintenanceStatus() {
const status = await this.redis.get(this.MAINTENANCE_KEY);
return {
isEnabled: status === 'true',
message:
status === 'true' ? 'System is under maintenance' : 'System is normal',
};
}
/**
* ตั้งค่า Maintenance Mode
*/
async setMaintenanceMode(dto: SetMaintenanceDto) {
if (dto.enabled) {
await this.redis.set(this.MAINTENANCE_KEY, 'true');
// เก็บเหตุผลไว้ใน Key อื่นก็ได้ถ้าต้องการ แต่เบื้องต้น Guard เช็คแค่ Key นี้
this.logger.warn(
`⚠️ SYSTEM ENTERED MAINTENANCE MODE: ${dto.reason || 'No reason provided'}`,
);
} else {
await this.redis.del(this.MAINTENANCE_KEY);
this.logger.log('✅ System exited maintenance mode');
}
return this.getMaintenanceStatus();
}
}

View File

@@ -5,6 +5,7 @@ import {
CreateDateColumn,
ManyToOne,
JoinColumn,
PrimaryColumn, // ✅ [Fix] เพิ่ม Import นี้
} from 'typeorm';
import { User } from '../../user/entities/user.entity';
@@ -44,7 +45,9 @@ export class Notification {
@Column({ name: 'entity_id', nullable: true })
entityId?: number;
// ✅ [Fix] รวม Decorator ไว้ที่นี่ที่เดียว (เป็นทั้ง CreateDate และ PrimaryColumn สำหรับ Partition)
@CreateDateColumn({ name: 'created_at' })
@PrimaryColumn()
createdAt!: Date;
// --- Relations ---

View File

@@ -1,26 +1,43 @@
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Job } from 'bullmq';
// File: src/modules/notification/notification.processor.ts
import { Processor, WorkerHost, InjectQueue } from '@nestjs/bullmq';
import { Job, Queue } from 'bullmq';
import { Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRedis } from '@nestjs-modules/ioredis';
import Redis from 'ioredis';
import * as nodemailer from 'nodemailer';
import axios from 'axios';
import { UserService } from '../user/user.service';
interface NotificationPayload {
userId: number;
title: string;
message: string;
link: string;
type: 'EMAIL' | 'LINE' | 'SYSTEM';
}
@Processor('notifications')
export class NotificationProcessor extends WorkerHost {
private readonly logger = new Logger(NotificationProcessor.name);
private mailerTransport: nodemailer.Transporter;
// ค่าคงที่สำหรับ Digest (เช่น รอ 5 นาที)
private readonly DIGEST_DELAY = 5 * 60 * 1000;
constructor(
private configService: ConfigService,
private userService: UserService,
@InjectQueue('notifications') private notificationQueue: Queue,
@InjectRedis() private readonly redis: Redis,
) {
super();
// Setup Nodemailer
this.mailerTransport = nodemailer.createTransport({
host: this.configService.get('SMTP_HOST'),
port: this.configService.get('SMTP_PORT'),
port: Number(this.configService.get('SMTP_PORT')),
secure: this.configService.get('SMTP_SECURE') === 'true',
auth: {
user: this.configService.get('SMTP_USER'),
@@ -30,59 +47,196 @@ export class NotificationProcessor extends WorkerHost {
}
async process(job: Job<any, any, string>): Promise<any> {
this.logger.debug(`Processing job ${job.name} for user ${job.data.userId}`);
this.logger.debug(`Processing job ${job.name} (ID: ${job.id})`);
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}`);
try {
switch (job.name) {
case 'dispatch-notification':
// Job หลัก: ตัดสินใจว่าจะส่งเลย หรือจะเข้า Digest Queue
return this.handleDispatch(job.data);
case 'process-digest':
// Job รอง: ทำงานเมื่อครบเวลา Delay เพื่อส่งแบบรวม
return this.handleProcessDigest(job.data.userId, job.data.type);
default:
throw new Error(`Unknown job name: ${job.name}`);
}
} catch (error) {
// ✅ แก้ไขตรงนี้: Type Casting (error as Error)
this.logger.error(
`Failed to process job ${job.name}: ${(error as Error).message}`,
(error as Error).stack,
);
throw error; // ให้ BullMQ จัดการ Retry
}
}
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`);
/**
* ฟังก์ชันตัดสินใจ (Dispatcher)
* ตรวจสอบ User Preferences และ Digest Mode
*/
private async handleDispatch(data: NotificationPayload) {
// 1. ดึง User พร้อม Preferences
const user: any = await this.userService.findOne(data.userId);
if (!user) {
this.logger.warn(`User ${data.userId} not found, skipping notification.`);
return;
}
const prefs = user.preferences || {
notify_email: true,
notify_line: true,
digest_mode: false,
};
// 2. ตรวจสอบว่า User ปิดรับการแจ้งเตือนหรือไม่
if (data.type === 'EMAIL' && !prefs.notify_email) return;
if (data.type === 'LINE' && !prefs.notify_line) return;
// 3. ตรวจสอบ Digest Mode
if (prefs.digest_mode) {
await this.addToDigest(data);
} else {
// ส่งทันที (Real-time)
if (data.type === 'EMAIL') await this.sendEmailImmediate(user, data);
if (data.type === 'LINE') await this.sendLineImmediate(user, data);
}
}
/**
* เพิ่มข้อความลงใน Redis List และตั้งเวลาส่ง (Delayed Job)
*/
private async addToDigest(data: NotificationPayload) {
const key = `digest:${data.type}:${data.userId}`;
// 1. Push ข้อมูลลง Redis List
await this.redis.rpush(key, JSON.stringify(data));
// 2. ตรวจสอบว่ามี "ตัวนับเวลาถอยหลัง" (Delayed Job) อยู่หรือยัง?
const lockKey = `digest:lock:${data.type}:${data.userId}`;
const isLocked = await this.redis.get(lockKey);
if (!isLocked) {
// ถ้ายังไม่มี Job รออยู่ ให้สร้างใหม่
await this.notificationQueue.add(
'process-digest',
{ userId: data.userId, type: data.type },
{
delay: this.DIGEST_DELAY,
jobId: `digest-${data.type}-${data.userId}-${Date.now()}`,
},
);
// Set Lock ไว้ตามเวลา Delay เพื่อไม่ให้สร้าง Job ซ้ำ
await this.redis.set(lockKey, '1', 'PX', this.DIGEST_DELAY);
this.logger.log(
`Scheduled digest for User ${data.userId} (${data.type}) in ${this.DIGEST_DELAY}ms`,
);
}
}
/**
* ประมวลผล Digest (ส่งแบบรวม)
*/
private async handleProcessDigest(userId: number, type: 'EMAIL' | 'LINE') {
const key = `digest:${type}:${userId}`;
const lockKey = `digest:lock:${type}:${userId}`;
// 1. ดึงข้อความทั้งหมดจาก Redis และลบออกทันที
const messagesRaw = await this.redis.lrange(key, 0, -1);
await this.redis.del(key);
await this.redis.del(lockKey); // Clear lock
if (!messagesRaw || messagesRaw.length === 0) return;
const messages: NotificationPayload[] = messagesRaw.map((m) =>
JSON.parse(m),
);
const user = await this.userService.findOne(userId);
if (type === 'EMAIL') {
await this.sendEmailDigest(user, messages);
} else if (type === 'LINE') {
await this.sendLineDigest(user, messages);
}
}
// =====================================================
// SENDERS (Immediate & Digest)
// =====================================================
private async sendEmailImmediate(user: any, data: NotificationPayload) {
if (!user.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>
`,
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');
private async sendEmailDigest(user: any, messages: NotificationPayload[]) {
if (!user.email) return;
if (!n8nWebhookUrl) {
this.logger.warn('N8N_LINE_WEBHOOK_URL not configured');
return;
}
// สร้าง HTML List
const listItems = messages
.map(
(msg) =>
`<li><strong>${msg.title}</strong>: ${msg.message} <a href="${msg.link}">[View]</a></li>`,
)
.join('');
await this.mailerTransport.sendMail({
from: '"LCBP3 DMS" <no-reply@np-dms.work>',
to: user.email,
subject: `[DMS Summary] คุณมีการแจ้งเตือนใหม่ ${messages.length} รายการ`,
html: `
<h3>สรุปรายการแจ้งเตือน (Digest)</h3>
<ul>${listItems}</ul>
<p>คุณได้รับอีเมลนี้เพราะเปิดใช้งานโหมดสรุปรายการ</p>
`,
});
this.logger.log(
`Digest Email sent to ${user.email} (${messages.length} items)`,
);
}
private async sendLineImmediate(user: any, data: NotificationPayload) {
const n8nWebhookUrl = this.configService.get('N8N_LINE_WEBHOOK_URL');
if (!n8nWebhookUrl) return;
try {
await axios.post(n8nWebhookUrl, {
userId: user.user_id, // หรือ user.lineId ถ้ามี
userId: user.user_id,
message: `${data.title}\n${data.message}`,
link: data.link,
isDigest: false,
});
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}`);
} catch (error) {
// ✅ แก้ไขตรงนี้ด้วย: Type Casting (error as Error)
this.logger.error(`Line Error: ${(error as Error).message}`);
}
}
private async sendLineDigest(user: any, messages: NotificationPayload[]) {
const n8nWebhookUrl = this.configService.get('N8N_LINE_WEBHOOK_URL');
if (!n8nWebhookUrl) return;
const summary = messages.map((m, i) => `${i + 1}. ${m.title}`).join('\n');
try {
await axios.post(n8nWebhookUrl, {
userId: user.user_id,
message: `สรุป ${messages.length} รายการใหม่:\n${summary}`,
link: 'https://lcbp3.np-dms.work/notifications',
isDigest: true,
});
} catch (error) {
// ✅ แก้ไขตรงนี้ด้วย: Type Casting (error as Error)
this.logger.error(`Line Digest Error: ${(error as Error).message}`);
}
}
}

View File

@@ -1,4 +1,5 @@
// File: src/modules/notification/notification.service.ts
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
@@ -22,9 +23,9 @@ export interface NotificationJobData {
title: string;
message: string;
type: 'EMAIL' | 'LINE' | 'SYSTEM'; // ช่องทางหลักที่ต้องการส่ง (Trigger Type)
entityType?: string; // e.g., 'rfa', 'correspondence'
entityId?: number; // e.g., rfa_id
link?: string; // Deep link to frontend page
entityType?: string;
entityId?: number;
link?: string;
}
@Injectable()
@@ -37,109 +38,57 @@ export class NotificationService {
private notificationRepo: Repository<Notification>,
@InjectRepository(User)
private userRepo: Repository<User>,
@InjectRepository(UserPreference)
private userPrefRepo: Repository<UserPreference>,
// ไม่ต้อง Inject UserPrefRepo แล้ว เพราะ Processor จะจัดการเอง
private notificationGateway: NotificationGateway,
) {}
/**
* ส่งการแจ้งเตือน (Centralized Notification Sender)
* 1. บันทึก DB (System Log)
* 2. ส่ง Real-time (WebSocket)
* 3. ส่ง External (Email/Line) ผ่าน Queue ตาม User Preference
*/
async send(data: NotificationJobData): Promise<void> {
try {
// ---------------------------------------------------------
// 1. สร้าง Entity และบันทึกลง DB (เพื่อให้มี History ในระบบ)
// 1. สร้าง Entity และบันทึกลง DB (System Log)
// ---------------------------------------------------------
const notification = this.notificationRepo.create({
userId: data.userId,
title: data.title,
message: data.message,
notificationType: NotificationType.SYSTEM, // ใน DB เก็บเป็น SYSTEM เสมอเพื่อแสดงใน App
notificationType: NotificationType.SYSTEM,
entityType: data.entityType,
entityId: data.entityId,
isRead: false,
// link: data.link // ถ้า Entity มี field link ให้ใส่ด้วย
});
const savedNotification = await this.notificationRepo.save(notification);
// ---------------------------------------------------------
// 2. Real-time Push (WebSocket) -> ส่งให้ User ทันทีถ้า Online
// 2. Real-time Push (WebSocket)
// ---------------------------------------------------------
this.notificationGateway.sendToUser(data.userId, savedNotification);
// ---------------------------------------------------------
// 3. ตรวจสอบ User Preferences เพื่อส่งช่องทางอื่น (Email/Line)
// 3. Push Job ลง Redis BullMQ (Dispatch Logic)
// เปลี่ยนชื่อ Job เป็น 'dispatch-notification' ตาม Processor
// ---------------------------------------------------------
const userPref = await this.userPrefRepo.findOne({
where: { userId: data.userId },
});
// ใช้ Nullish Coalescing Operator (??)
// ถ้าไม่มีค่า (undefined/null) ให้ Default เป็น true
const shouldSendEmail = userPref?.notifyEmail ?? true;
const shouldSendLine = userPref?.notifyLine ?? true;
const jobs = [];
// ---------------------------------------------------------
// 4. เตรียม Job สำหรับ Email Queue
// เงื่อนไข: User เปิดรับ Email และ Noti นี้ไม่ได้บังคับส่งแค่ LINE
// ---------------------------------------------------------
if (shouldSendEmail && data.type !== 'LINE') {
jobs.push({
name: 'send-email',
data: {
...data,
notificationId: savedNotification.id,
target: 'EMAIL',
await this.notificationQueue.add(
'dispatch-notification',
{
...data,
notificationId: savedNotification.id, // ส่ง ID ไปด้วยเผื่อใช้ Tracking
},
{
attempts: 3,
backoff: {
type: 'exponential',
delay: 5000,
},
opts: {
attempts: 3, // ลองใหม่ 3 ครั้งถ้าล่ม (Resilience)
backoff: {
type: 'exponential',
delay: 5000, // รอ 5s, 10s, 20s...
},
removeOnComplete: true, // ลบ Job เมื่อเสร็จ (ประหยัด Redis Memory)
},
});
}
removeOnComplete: true,
},
);
// ---------------------------------------------------------
// 5. เตรียม Job สำหรับ Line Queue
// เงื่อนไข: User เปิดรับ Line และ Noti นี้ไม่ได้บังคับส่งแค่ EMAIL
// ---------------------------------------------------------
if (shouldSendLine && data.type !== 'EMAIL') {
jobs.push({
name: 'send-line',
data: {
...data,
notificationId: savedNotification.id,
target: 'LINE',
},
opts: {
attempts: 3,
backoff: { type: 'fixed', delay: 3000 },
removeOnComplete: true,
},
});
}
// ---------------------------------------------------------
// 6. Push Jobs ลง Redis BullMQ
// ---------------------------------------------------------
if (jobs.length > 0) {
await this.notificationQueue.addBulk(jobs);
this.logger.debug(
`Queued ${jobs.length} external notifications for user ${data.userId}`,
);
}
this.logger.debug(`Dispatched notification job for user ${data.userId}`);
} catch (error) {
// Error Handling: ไม่ Throw เพื่อไม่ให้ Flow หลัก (เช่น การสร้างเอกสาร) พัง
// แต่บันทึก Error ไว้ตรวจสอบ
this.logger.error(
`Failed to process notification for user ${data.userId}`,
(error as Error).stack,
@@ -147,9 +96,8 @@ export class NotificationService {
}
}
/**
* ดึงรายการแจ้งเตือนของ User (สำหรับ Controller)
*/
// ... (ส่วน findAll, markAsRead, cleanupOldNotifications เหมือนเดิม ไม่ต้องแก้) ...
async findAll(userId: number, searchDto: SearchNotificationDto) {
const { page = 1, limit = 20, isRead } = searchDto;
const skip = (page - 1) * limit;
@@ -161,14 +109,11 @@ export class NotificationService {
.take(limit)
.skip(skip);
// Filter by Read Status (ถ้ามีการส่งมา)
if (isRead !== undefined) {
queryBuilder.andWhere('notification.isRead = :isRead', { isRead });
}
const [items, total] = await queryBuilder.getManyAndCount();
// นับจำนวนที่ยังไม่ได้อ่านทั้งหมด (เพื่อแสดง Badge ที่กระดิ่ง)
const unreadCount = await this.notificationRepo.count({
where: { userId, isRead: false },
});
@@ -185,9 +130,6 @@ export class NotificationService {
};
}
/**
* อ่านแจ้งเตือน (Mark as Read)
*/
async markAsRead(id: number, userId: number): Promise<void> {
const notification = await this.notificationRepo.findOne({
where: { id, userId },
@@ -200,15 +142,9 @@ export class NotificationService {
if (!notification.isRead) {
notification.isRead = true;
await this.notificationRepo.save(notification);
// Update Unread Count via WebSocket (Optional)
// this.notificationGateway.sendUnreadCount(userId, ...);
}
}
/**
* อ่านทั้งหมด (Mark All as Read)
*/
async markAllAsRead(userId: number): Promise<void> {
await this.notificationRepo.update(
{ userId, isRead: false },
@@ -216,10 +152,6 @@ export class NotificationService {
);
}
/**
* ลบการแจ้งเตือนที่เก่าเกินกำหนด (ใช้กับ Cron Job Cleanup)
* เก็บไว้ 90 วัน
*/
async cleanupOldNotifications(days: number = 90): Promise<number> {
const dateLimit = new Date();
dateLimit.setDate(dateLimit.getDate() - days);

View File

@@ -64,6 +64,7 @@ export class UserService {
async findOne(id: number): Promise<User> {
const user = await this.usersRepository.findOne({
where: { user_id: id },
relations: ['preferences', 'roles'], // [IMPORTANT] ต้องโหลด preferences มาด้วย
});
if (!user) {