Main: revise specs to 1.5.0 (completed)
This commit is contained in:
524
specs/06-tasks/TASK-BE-011-notification-audit.md
Normal file
524
specs/06-tasks/TASK-BE-011-notification-audit.md
Normal file
@@ -0,0 +1,524 @@
|
||||
# Task: Notification & Audit Log Services
|
||||
|
||||
**Status:** Not Started
|
||||
**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
|
||||
Reference in New Issue
Block a user