525 lines
12 KiB
Markdown
525 lines
12 KiB
Markdown
# Task: Notification & Audit Log Services
|
|
|
|
**Status:** Completed
|
|
**Priority:** P3 (Low - Supporting Services)
|
|
**Estimated Effort:** 3-5 days
|
|
**Dependencies:** TASK-BE-001, TASK-BE-002
|
|
**Owner:** Backend Team
|
|
|
|
---
|
|
|
|
## 📋 Overview
|
|
|
|
สร้าง Notification Service สำหรับส่งการแจ้งเตือน และ Audit Log Service สำหรับบันทึกประวัติการใช้งานระบบ
|
|
|
|
---
|
|
|
|
## 🎯 Objectives
|
|
|
|
- ✅ Email Notification
|
|
- ✅ LINE Notify Integration
|
|
- ✅ In-App Notifications
|
|
- ✅ Audit Log Recording
|
|
- ✅ Audit Log Query & Export
|
|
|
|
---
|
|
|
|
## 📝 Acceptance Criteria
|
|
|
|
1. **Notifications:**
|
|
|
|
- ✅ Send email via queue
|
|
- ✅ Send LINE Notify
|
|
- ✅ Store in-app notifications
|
|
- ✅ Mark notifications as read
|
|
- ✅ Notification templates
|
|
|
|
2. **Audit Logs:**
|
|
- ✅ Auto-log CRUD operations
|
|
- ✅ Log workflow transitions
|
|
- ✅ Query audit logs by user/entity
|
|
- ✅ Export to CSV
|
|
|
|
---
|
|
|
|
## 🛠️ Implementation Steps
|
|
|
|
### 1. Notification Entity
|
|
|
|
```typescript
|
|
// File: backend/src/modules/notification/entities/notification.entity.ts
|
|
@Entity('notifications')
|
|
export class Notification {
|
|
@PrimaryGeneratedColumn()
|
|
id: number;
|
|
|
|
@Column()
|
|
user_id: number;
|
|
|
|
@Column({ length: 100 })
|
|
notification_type: string;
|
|
|
|
@Column({ length: 500 })
|
|
title: string;
|
|
|
|
@Column({ type: 'text' })
|
|
message: string;
|
|
|
|
@Column({ length: 255, nullable: true })
|
|
link: string;
|
|
|
|
@Column({ default: false })
|
|
is_read: boolean;
|
|
|
|
@Column({ type: 'timestamp', nullable: true })
|
|
read_at: Date;
|
|
|
|
@CreateDateColumn()
|
|
created_at: Date;
|
|
|
|
@ManyToOne(() => User)
|
|
@JoinColumn({ name: 'user_id' })
|
|
user: User;
|
|
}
|
|
```
|
|
|
|
### 2. Notification Service
|
|
|
|
```typescript
|
|
// File: backend/src/modules/notification/notification.service.ts
|
|
import { Injectable } from '@nestjs/common';
|
|
import { Queue } from 'bullmq';
|
|
|
|
@Injectable()
|
|
export class NotificationService {
|
|
constructor(
|
|
@InjectRepository(Notification)
|
|
private notificationRepo: Repository<Notification>,
|
|
@InjectQueue('email') private emailQueue: Queue,
|
|
@InjectQueue('line-notify') private lineQueue: Queue
|
|
) {}
|
|
|
|
async createNotification(dto: CreateNotificationDto): Promise<Notification> {
|
|
const notification = this.notificationRepo.create({
|
|
user_id: dto.user_id,
|
|
notification_type: dto.type,
|
|
title: dto.title,
|
|
message: dto.message,
|
|
link: dto.link,
|
|
});
|
|
|
|
return this.notificationRepo.save(notification);
|
|
}
|
|
|
|
async sendEmail(dto: SendEmailDto): Promise<void> {
|
|
await this.emailQueue.add('send-email', {
|
|
to: dto.to,
|
|
subject: dto.subject,
|
|
template: dto.template,
|
|
context: dto.context,
|
|
});
|
|
}
|
|
|
|
async sendLineNotify(dto: SendLineNotifyDto): Promise<void> {
|
|
await this.lineQueue.add('send-line', {
|
|
token: dto.token,
|
|
message: dto.message,
|
|
});
|
|
}
|
|
|
|
async notifyWorkflowTransition(
|
|
workflowId: number,
|
|
action: string,
|
|
actorId: number
|
|
): Promise<void> {
|
|
// Get relevant users to notify
|
|
const users = await this.getRelevantUsers(workflowId);
|
|
|
|
for (const user of users) {
|
|
// Create in-app notification
|
|
await this.createNotification({
|
|
user_id: user.user_id,
|
|
type: 'workflow_transition',
|
|
title: `${action} completed`,
|
|
message: `Workflow ${workflowId} has been ${action}`,
|
|
link: `/workflows/${workflowId}`,
|
|
});
|
|
|
|
// Send email
|
|
if (user.email_notifications_enabled) {
|
|
await this.sendEmail({
|
|
to: user.email,
|
|
subject: `Workflow Update`,
|
|
template: 'workflow-transition',
|
|
context: { action, workflowId },
|
|
});
|
|
}
|
|
|
|
// Send LINE
|
|
if (user.line_notify_token) {
|
|
await this.sendLineNotify({
|
|
token: user.line_notify_token,
|
|
message: `Workflow ${workflowId}: ${action}`,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
async getUserNotifications(
|
|
userId: number,
|
|
unreadOnly: boolean = false
|
|
): Promise<Notification[]> {
|
|
const query: any = { user_id: userId };
|
|
if (unreadOnly) {
|
|
query.is_read = false;
|
|
}
|
|
|
|
return this.notificationRepo.find({
|
|
where: query,
|
|
order: { created_at: 'DESC' },
|
|
take: 50,
|
|
});
|
|
}
|
|
|
|
async markAsRead(notificationId: number, userId: number): Promise<void> {
|
|
await this.notificationRepo.update(
|
|
{ id: notificationId, user_id: userId },
|
|
{ is_read: true, read_at: new Date() }
|
|
);
|
|
}
|
|
|
|
async markAllAsRead(userId: number): Promise<void> {
|
|
await this.notificationRepo.update(
|
|
{ user_id: userId, is_read: false },
|
|
{ is_read: true, read_at: new Date() }
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
### 3. Email Queue Processor
|
|
|
|
```typescript
|
|
// File: backend/src/modules/notification/processors/email.processor.ts
|
|
import { Processor, Process } from '@nestjs/bullmq';
|
|
import { Job } from 'bullmq';
|
|
import * as nodemailer from 'nodemailer';
|
|
import * as handlebars from 'handlebars';
|
|
|
|
@Processor('email')
|
|
export class EmailProcessor {
|
|
private transporter: nodemailer.Transporter;
|
|
|
|
constructor() {
|
|
this.transporter = nodemailer.createTransport({
|
|
host: process.env.SMTP_HOST,
|
|
port: parseInt(process.env.SMTP_PORT),
|
|
secure: true,
|
|
auth: {
|
|
user: process.env.SMTP_USER,
|
|
pass: process.env.SMTP_PASS,
|
|
},
|
|
});
|
|
}
|
|
|
|
@Process('send-email')
|
|
async sendEmail(job: Job<any>) {
|
|
const { to, subject, template, context } = job.data;
|
|
|
|
// Load template
|
|
const templatePath = `./templates/emails/${template}.hbs`;
|
|
const templateSource = await fs.readFile(templatePath, 'utf-8');
|
|
const compiledTemplate = handlebars.compile(templateSource);
|
|
const html = compiledTemplate(context);
|
|
|
|
// Send email
|
|
await this.transporter.sendMail({
|
|
from: process.env.SMTP_FROM,
|
|
to,
|
|
subject,
|
|
html,
|
|
});
|
|
}
|
|
}
|
|
```
|
|
|
|
### 4. LINE Notify Processor
|
|
|
|
```typescript
|
|
// File: backend/src/modules/notification/processors/line-notify.processor.ts
|
|
@Processor('line-notify')
|
|
export class LineNotifyProcessor {
|
|
@Process('send-line')
|
|
async sendLineNotify(job: Job<any>) {
|
|
const { token, message } = job.data;
|
|
|
|
await axios.post(
|
|
'https://notify-api.line.me/api/notify',
|
|
`message=${encodeURIComponent(message)}`,
|
|
{
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
}
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
### 5. Audit Log Service
|
|
|
|
```typescript
|
|
// File: backend/src/modules/audit/audit.service.ts
|
|
@Injectable()
|
|
export class AuditService {
|
|
constructor(
|
|
@InjectRepository(AuditLog)
|
|
private auditRepo: Repository<AuditLog>
|
|
) {}
|
|
|
|
async log(dto: CreateAuditLogDto): Promise<void> {
|
|
const auditLog = this.auditRepo.create({
|
|
user_id: dto.user_id,
|
|
action: dto.action,
|
|
entity_type: dto.entity_type,
|
|
entity_id: dto.entity_id,
|
|
changes: dto.changes,
|
|
ip_address: dto.ip_address,
|
|
user_agent: dto.user_agent,
|
|
});
|
|
|
|
await this.auditRepo.save(auditLog);
|
|
}
|
|
|
|
async findByEntity(
|
|
entityType: string,
|
|
entityId: number
|
|
): Promise<AuditLog[]> {
|
|
return this.auditRepo.find({
|
|
where: { entity_type: entityType, entity_id: entityId },
|
|
relations: ['user'],
|
|
order: { created_at: 'DESC' },
|
|
});
|
|
}
|
|
|
|
async findByUser(userId: number, limit: number = 100): Promise<AuditLog[]> {
|
|
return this.auditRepo.find({
|
|
where: { user_id: userId },
|
|
order: { created_at: 'DESC' },
|
|
take: limit,
|
|
});
|
|
}
|
|
|
|
async exportToCsv(query: AuditQueryDto): Promise<string> {
|
|
const logs = await this.auditRepo.find({
|
|
where: this.buildWhereClause(query),
|
|
relations: ['user'],
|
|
order: { created_at: 'DESC' },
|
|
});
|
|
|
|
// Generate CSV
|
|
const csv = logs
|
|
.map((log) =>
|
|
[
|
|
log.created_at,
|
|
log.user.username,
|
|
log.action,
|
|
log.entity_type,
|
|
log.entity_id,
|
|
log.ip_address,
|
|
].join(',')
|
|
)
|
|
.join('\n');
|
|
|
|
return `Timestamp,User,Action,Entity Type,Entity ID,IP Address\n${csv}`;
|
|
}
|
|
}
|
|
```
|
|
|
|
### 6. Audit Interceptor
|
|
|
|
```typescript
|
|
// File: backend/src/common/interceptors/audit.interceptor.ts
|
|
@Injectable()
|
|
export class AuditInterceptor implements NestInterceptor {
|
|
constructor(private auditService: AuditService) {}
|
|
|
|
intercept(context: ExecutionContext, next: CallHandler) {
|
|
const request = context.switchToHttp().getRequest();
|
|
const { method, url, user, ip, headers } = request;
|
|
|
|
// Only audit write operations
|
|
if (!['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
|
|
return next.handle();
|
|
}
|
|
|
|
return next.handle().pipe(
|
|
tap(async (response) => {
|
|
// Extract entity info from URL
|
|
const match = url.match(/\/(\w+)\/(\d+)?/);
|
|
if (match) {
|
|
const [, entityType, entityId] = match;
|
|
|
|
await this.auditService.log({
|
|
user_id: user?.user_id,
|
|
action: `${method} ${entityType}`,
|
|
entity_type: entityType,
|
|
entity_id: entityId ? parseInt(entityId) : null,
|
|
changes: JSON.stringify(request.body),
|
|
ip_address: ip,
|
|
user_agent: headers['user-agent'],
|
|
});
|
|
}
|
|
})
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
### 7. Controllers
|
|
|
|
```typescript
|
|
// File: backend/src/modules/notification/notification.controller.ts
|
|
@Controller('notifications')
|
|
@UseGuards(JwtAuthGuard)
|
|
export class NotificationController {
|
|
constructor(private service: NotificationService) {}
|
|
|
|
@Get('my')
|
|
async getMyNotifications(
|
|
@CurrentUser() user: User,
|
|
@Query('unread_only') unreadOnly: boolean
|
|
) {
|
|
return this.service.getUserNotifications(user.user_id, unreadOnly);
|
|
}
|
|
|
|
@Post(':id/read')
|
|
async markAsRead(
|
|
@Param('id', ParseIntPipe) id: number,
|
|
@CurrentUser() user: User
|
|
) {
|
|
return this.service.markAsRead(id, user.user_id);
|
|
}
|
|
|
|
@Post('read-all')
|
|
async markAllAsRead(@CurrentUser() user: User) {
|
|
return this.service.markAllAsRead(user.user_id);
|
|
}
|
|
}
|
|
```
|
|
|
|
```typescript
|
|
// File: backend/src/modules/audit/audit.controller.ts
|
|
@Controller('audit-logs')
|
|
@UseGuards(JwtAuthGuard, PermissionGuard)
|
|
export class AuditController {
|
|
constructor(private service: AuditService) {}
|
|
|
|
@Get('entity/:type/:id')
|
|
@RequirePermission('audit.view')
|
|
async getEntityAuditLogs(
|
|
@Param('type') type: string,
|
|
@Param('id', ParseIntPipe) id: number
|
|
) {
|
|
return this.service.findByEntity(type, id);
|
|
}
|
|
|
|
@Get('export')
|
|
@RequirePermission('audit.export')
|
|
async exportAuditLogs(@Query() query: AuditQueryDto, @Res() res: Response) {
|
|
const csv = await this.service.exportToCsv(query);
|
|
|
|
res.setHeader('Content-Type', 'text/csv');
|
|
res.setHeader('Content-Disposition', 'attachment; filename=audit-logs.csv');
|
|
res.send(csv);
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## ✅ Testing & Verification
|
|
|
|
### 1. Unit Tests
|
|
|
|
```typescript
|
|
describe('NotificationService', () => {
|
|
it('should create in-app notification', async () => {
|
|
const result = await service.createNotification({
|
|
user_id: 1,
|
|
type: 'info',
|
|
title: 'Test',
|
|
message: 'Test message',
|
|
});
|
|
|
|
expect(result.id).toBeDefined();
|
|
});
|
|
|
|
it('should queue email for sending', async () => {
|
|
await service.sendEmail({
|
|
to: 'test@example.com',
|
|
subject: 'Test',
|
|
template: 'test',
|
|
context: {},
|
|
});
|
|
|
|
expect(emailQueue.add).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('AuditService', () => {
|
|
it('should log audit event', async () => {
|
|
await service.log({
|
|
user_id: 1,
|
|
action: 'CREATE correspondence',
|
|
entity_type: 'correspondence',
|
|
entity_id: 10,
|
|
});
|
|
|
|
const logs = await service.findByEntity('correspondence', 10);
|
|
expect(logs).toHaveLength(1);
|
|
});
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## 📚 Related Documents
|
|
|
|
- [System Architecture - Notifications](../02-architecture/system-architecture.md#notifications)
|
|
|
|
---
|
|
|
|
## 📦 Deliverables
|
|
|
|
- [ ] NotificationService (Email, LINE, In-App)
|
|
- [ ] Email & LINE Queue Processors
|
|
- [ ] Email Templates (Handlebars)
|
|
- [ ] AuditService
|
|
- [ ] Audit Interceptor
|
|
- [ ] Controllers
|
|
- [ ] Unit Tests (75% coverage)
|
|
- [ ] API Documentation
|
|
|
|
---
|
|
|
|
## 🚨 Risks & Mitigation
|
|
|
|
| Risk | Impact | Mitigation |
|
|
| --------------------- | ------ | -------------------------- |
|
|
| Email service down | Low | Queue retry logic |
|
|
| LINE token expiration | Low | Token refresh mechanism |
|
|
| Audit log volume | Medium | Archive old logs, indexing |
|
|
|
|
---
|
|
|
|
## 📌 Notes
|
|
|
|
- Email sent via queue (async)
|
|
- LINE Notify requires user token setup
|
|
- In-app notifications stored in DB
|
|
- Audit logs auto-generated via interceptor
|
|
- Export audit logs to CSV
|
|
- Email templates use Handlebars
|