Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions docs/api/NOTIFICATION_API_GUIDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# Notification API Guide

## 1. Tổng quan
- Hỗ trợ **thông báo realtime cho sinh viên** qua WebSocket.
- Các sự kiện đã phát hiện và gửi notification:
- Nạp tiền thành công (deposit webhook SePay).
- Print job hoàn thành.
- Print job bị hủy.
- Mỗi notification được lưu DB (bảng `notification`) và có thể đẩy realtime nếu client đang subscribe.

## 2. WebSocket Notifications

- **WebSocket endpoint:** `/ws`
- **Subscribe (STOMP):** gửi message đến `/app/students/{studentId}/notifications/subscribe`
- **Unsubscribe:** gửi message đến `/app/students/{studentId}/notifications/unsubscribe`
- **Topic nhận:** `/topic/students/{studentId}/notifications`

### 2.1 Message format
```json
{
"type": "notification",
"data": {
"notificationId": "f2c9c2f1-6c3d-4b4d-b12c-4bcbcb8c1234",
"notificationType": "DEPOSIT_COMPLETED",
"title": "Nạp tiền thành công",
"message": "Bạn đã nạp thành công 50,000 VND vào tài khoản SmartPrint.",
"isRead": false,
"createdAt": "2025-12-23T10:00:00"
},
"timestamp": "2025-12-23T10:00:00"
}
```

- `type`: luôn `"notification"` cho thông báo push.
- `notificationType` gợi ý:
- `DEPOSIT_COMPLETED`
- `PRINT_JOB_COMPLETED`
- `PRINT_JOB_CANCELLED`

### 2.2 Trình tự client
1) Kết nối SockJS/STOMP tới `/ws`.
2) Gửi subscribe:
- Destination: `/app/students/{studentId}/notifications/subscribe`
- Body: rỗng.
3) Lắng nghe topic `/topic/students/{studentId}/notifications`.
4) Khi nhận message `type=notification`, hiển thị nội dung `title`, `message`.
5) Khi rời màn hình: gửi `/app/students/{studentId}/notifications/unsubscribe`.

### 2.3 Ví dụ JS (tóm tắt)
```javascript
const socket = new SockJS('/ws');
const stomp = Stomp.over(socket);
stomp.connect({}, () => {
stomp.subscribe(`/topic/students/${studentId}/notifications`, (msg) => {
const payload = JSON.parse(msg.body);
if (payload.type === 'notification') {
console.log('Notification:', payload.data.title, payload.data.message);
}
});
stomp.send(`/app/students/${studentId}/notifications/subscribe`, {}, {});
});
```

## 3. Các sự kiện hiện đã hook
- **Nạp tiền thành công (SePay webhook):**
- Tạo bản ghi notification với `notificationType = "DEPOSIT_COMPLETED"`.
- Đẩy WS nếu có subscriber của sinh viên.
- **Print job hoàn thành:**
- Khi job chuyển `printing → completed`, tạo notification `PRINT_JOB_COMPLETED`.
- **Print job bị hủy:**
- Khi hủy job (queued/pending_payment), tạo notification `PRINT_JOB_CANCELLED` (kèm thông tin refund nếu có).

## 4. Database schema (tóm tắt)
- Bảng `notification`:
- `notification_id` (PK)
- `student_id` (FK -> student)
- `notification_type` (VARCHAR 50)
- `title` (NVARCHAR 200)
- `message` (NVARCHAR 1000)
- `is_read` (BIT, default 0)
- `created_at` (DATETIME, default GETDATE)

## 5. REST endpoints (chưa triển khai)
- Dự kiến:
- `GET /api/students/notifications` — danh sách notification (paging).
- `POST /api/students/notifications/{notificationId}/read` — đánh dấu đã đọc.
- Hiện tại sử dụng WebSocket realtime; nếu cần REST, hãy yêu cầu để triển khai thêm.

## 6. Lưu ý bảo mật
- WebSocket hiện không enforce ownership trong subscribe handler; cần cấu hình frontend chỉ subscribe đúng `studentId` của người đăng nhập. Có thể tăng cường bằng cách map `userId -> studentId` từ JWT và kiểm tra trong controller/subscription.

Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package com.siu.controller;

import com.siu.dto.WebSocketMessage;
import com.siu.service.NotificationWebSocketService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.messaging.handler.annotation.DestinationVariable;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
import org.springframework.stereotype.Controller;

import java.time.LocalDateTime;
import java.util.Map;
import java.util.UUID;

/**
* WebSocket controller for student-level notifications.
* Client:
* - subscribe: /app/students/{studentId}/notifications/subscribe
* - unsubscribe: /app/students/{studentId}/notifications/unsubscribe
* - receive: /topic/students/{studentId}/notifications
*/
@Controller
@RequiredArgsConstructor
@Slf4j
public class StudentNotificationWebSocketController {

private final NotificationWebSocketService notificationWebSocketService;

@MessageMapping("/students/{studentId}/notifications/subscribe")
public WebSocketMessage subscribe(
@DestinationVariable UUID studentId,
SimpMessageHeaderAccessor headerAccessor
) {
String sessionId = headerAccessor.getSessionId();
notificationWebSocketService.subscribe(studentId, sessionId);
log.debug("Client {} subscribed to notifications for student {}", sessionId, studentId);

return WebSocketMessage.builder()
.type("subscribed")
.data(Map.of("studentId", studentId.toString()))
.timestamp(LocalDateTime.now())
.build();
}

@MessageMapping("/students/{studentId}/notifications/unsubscribe")
public void unsubscribe(
@DestinationVariable UUID studentId,
SimpMessageHeaderAccessor headerAccessor
) {
String sessionId = headerAccessor.getSessionId();
notificationWebSocketService.unsubscribe(studentId, sessionId);
log.debug("Client {} unsubscribed from notifications for student {}", sessionId, studentId);
}
}


38 changes: 38 additions & 0 deletions src/main/java/com/siu/dto/NotificationResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.siu.dto;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.time.LocalDateTime;
import java.util.UUID;

@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Schema(description = "Notification dto for student")
public class NotificationResponse {

@Schema(description = "Notification ID")
private UUID notificationId;

@Schema(description = "Notification type", example = "DEPOSIT_COMPLETED")
private String notificationType;

@Schema(description = "Title", example = "Nạp tiền thành công")
private String title;

@Schema(description = "Message", example = "Bạn đã nạp 50.000đ vào tài khoản SmartPrint")
private String message;

@Schema(description = "Read flag")
private Boolean isRead;

@Schema(description = "Created time")
private LocalDateTime createdAt;
}


61 changes: 61 additions & 0 deletions src/main/java/com/siu/entity/Notification.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package com.siu.entity;

import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

import java.time.LocalDateTime;
import java.util.UUID;

@Entity
@Table(name = "notification", indexes = {
@Index(name = "idx_notification_student", columnList = "student_id, created_at"),
@Index(name = "idx_notification_unread", columnList = "student_id, is_read, created_at")
})
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Notification {

@Id
@GeneratedValue(strategy = GenerationType.AUTO)
@Column(name = "notification_id", nullable = false)
private UUID notificationId;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "student_id", nullable = false)
private Student student;

@Column(name = "notification_type", nullable = false, length = 50)
private String notificationType;

@Column(name = "title", nullable = false, length = 200)
private String title;

@Column(name = "message", nullable = false, length = 1000)
private String message;

@Builder.Default
@Column(name = "is_read", nullable = false)
private Boolean isRead = false;

@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;

@PrePersist
protected void onCreate() {
if (createdAt == null) {
createdAt = LocalDateTime.now();
}
if (isRead == null) {
isRead = false;
}
}
}


17 changes: 17 additions & 0 deletions src/main/java/com/siu/repository/NotificationRepository.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.siu.repository;

import com.siu.entity.Notification;
import com.siu.entity.Student;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;
import java.util.UUID;

public interface NotificationRepository extends JpaRepository<Notification, UUID> {

List<Notification> findTop20ByStudentOrderByCreatedAtDesc(Student student);

List<Notification> findByStudentAndIsReadFalseOrderByCreatedAtDesc(Student student);
}


15 changes: 15 additions & 0 deletions src/main/java/com/siu/scheduler/PrintJobProgressUpdater.java
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ public class PrintJobProgressUpdater {
private final PrinterPhysicalRepository printerPhysicalRepository;
private final PrintJobWebSocketService webSocketService;
private final StudentPrintJobService studentPrintJobService;
private final com.siu.service.NotificationService notificationService;

// Page printing rate: configurable via application.properties
@Value("${app.print-job.page-printing-rate-seconds:10}")
Expand Down Expand Up @@ -225,6 +226,20 @@ private void completeJob(PrintJob job) {
job.setEndTime(LocalDateTime.now());
printJobRepository.save(job);

// Gửi notification cho sinh viên
try {
notificationService.createNotificationForStudent(
job.getStudent().getStudentId(),
"PRINT_JOB_COMPLETED",
"Print job hoàn thành",
String.format("Print job %s đã hoàn thành trên máy in %s.",
job.getJobId(),
job.getPrinter() != null ? job.getPrinter().getPrinterId() : "")
);
} catch (Exception e) {
log.warn("Failed to create notification for completed print job {}: {}", job.getJobId(), e.getMessage());
}

// Bước 2: Update printer status back to idle để Queue Processor xử lý job tiếp theo
PrinterPhysical printer = job.getPrinter();
if (printer != null) {
Expand Down
17 changes: 17 additions & 0 deletions src/main/java/com/siu/service/NotificationService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.siu.service;

import com.siu.dto.NotificationResponse;

import java.util.List;
import java.util.UUID;

public interface NotificationService {

void createNotificationForStudent(UUID studentId, String type, String title, String message);

List<NotificationResponse> getRecentNotificationsForStudent(UUID studentId);

void markNotificationAsRead(UUID studentId, UUID notificationId);
}


69 changes: 69 additions & 0 deletions src/main/java/com/siu/service/NotificationWebSocketService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package com.siu.service;

import com.siu.dto.NotificationResponse;
import com.siu.dto.WebSocketMessage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Service;

import java.time.LocalDateTime;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;

/**
* WebSocket service for per-student notifications.
* Topic: /topic/students/{studentId}/notifications
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class NotificationWebSocketService {

private final SimpMessagingTemplate messagingTemplate;

// studentId -> sessionIds
private final Map<UUID, Set<String>> subscribedStudents = new ConcurrentHashMap<>();

public void subscribe(UUID studentId, String sessionId) {
subscribedStudents.computeIfAbsent(studentId, k -> ConcurrentHashMap.newKeySet())
.add(sessionId);
log.debug("Client {} subscribed to notifications of student {}", sessionId, studentId);
}

public void unsubscribe(UUID studentId, String sessionId) {
Set<String> sessions = subscribedStudents.get(studentId);
if (sessions != null) {
sessions.remove(sessionId);
if (sessions.isEmpty()) {
subscribedStudents.remove(studentId);
}
log.debug("Client {} unsubscribed from notifications of student {}", sessionId, studentId);
}
}

public boolean hasSubscribers(UUID studentId) {
Set<String> sessions = subscribedStudents.get(studentId);
return sessions != null && !sessions.isEmpty();
}

public void sendNotification(UUID studentId, NotificationResponse notification) {
if (!hasSubscribers(studentId)) {
return;
}

WebSocketMessage message = WebSocketMessage.builder()
.type("notification")
.data(notification)
.timestamp(LocalDateTime.now())
.build();

String destination = "/topic/students/" + studentId + "/notifications";
messagingTemplate.convertAndSend(destination, message);
log.debug("Sent notification {} to student {}", notification.getNotificationId(), studentId);
}
}


Loading