From 29d8ba33fc3e62993750f2942e28c91c6b79c882 Mon Sep 17 00:00:00 2001 From: XpnsiveSharks Date: Thu, 2 Apr 2026 17:22:18 +0800 Subject: [PATCH] feat: add real-time sync, cache-first loading, and admin/student UX improvements --- .../controller/RealtimeController.java | 26 ++++++ .../controller/SyncController.java | 60 +++++++++++++ .../dto/RealtimeEventResponse.java | 19 ++++ .../com/ucms_backend/dto/SyncResponse.java | 19 ++++ .../repository/ProfileRepository.java | 3 + .../service/NotificationService.java | 22 ++++- .../ucms_backend/service/ProfileService.java | 24 ++++- .../service/RealtimeSseService.java | 89 +++++++++++++++++++ .../com/ucms_backend/service/SyncService.java | 89 +++++++++++++++++++ .../service/TicketResponseService.java | 18 +++- .../ucms_backend/service/TicketService.java | 66 +++++++++++++- 11 files changed, 430 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/ucms_backend/controller/RealtimeController.java create mode 100644 src/main/java/com/ucms_backend/controller/SyncController.java create mode 100644 src/main/java/com/ucms_backend/dto/RealtimeEventResponse.java create mode 100644 src/main/java/com/ucms_backend/dto/SyncResponse.java create mode 100644 src/main/java/com/ucms_backend/service/RealtimeSseService.java create mode 100644 src/main/java/com/ucms_backend/service/SyncService.java diff --git a/src/main/java/com/ucms_backend/controller/RealtimeController.java b/src/main/java/com/ucms_backend/controller/RealtimeController.java new file mode 100644 index 0000000..d6ea768 --- /dev/null +++ b/src/main/java/com/ucms_backend/controller/RealtimeController.java @@ -0,0 +1,26 @@ +package com.ucms_backend.controller; + +import com.ucms_backend.security.SecurityUtils; +import com.ucms_backend.service.RealtimeSseService; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +@RestController +@RequestMapping("/api/realtime") +public class RealtimeController { + + private final RealtimeSseService realtimeSseService; + + public RealtimeController(RealtimeSseService realtimeSseService) { + this.realtimeSseService = realtimeSseService; + } + + @GetMapping("/stream") + @PreAuthorize("hasAnyRole('STUDENT','ADMIN')") + public SseEmitter stream() { + return realtimeSseService.subscribe(SecurityUtils.getCurrentUserId()); + } +} diff --git a/src/main/java/com/ucms_backend/controller/SyncController.java b/src/main/java/com/ucms_backend/controller/SyncController.java new file mode 100644 index 0000000..3aee095 --- /dev/null +++ b/src/main/java/com/ucms_backend/controller/SyncController.java @@ -0,0 +1,60 @@ +package com.ucms_backend.controller; + +import com.ucms_backend.dto.AnalyticsOverviewResponse; +import com.ucms_backend.dto.ApiResponse; +import com.ucms_backend.dto.NotificationResponse; +import com.ucms_backend.dto.ProfileResponse; +import com.ucms_backend.dto.SyncResponse; +import com.ucms_backend.dto.TicketResponse; +import com.ucms_backend.service.SyncService; +import java.time.Instant; +import java.util.List; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/sync") +public class SyncController { + + private final SyncService syncService; + + public SyncController(SyncService syncService) { + this.syncService = syncService; + } + + @GetMapping("/profile") + @PreAuthorize("hasAnyRole('STUDENT','ADMIN')") + public ResponseEntity>> syncProfile( + @RequestParam(required = false) Instant since + ) { + return ResponseEntity.ok(ApiResponse.ok("Profile sync retrieved", syncService.syncProfile(since))); + } + + @GetMapping("/tickets") + @PreAuthorize("hasAnyRole('STUDENT','ADMIN')") + public ResponseEntity>>> syncTickets( + @RequestParam(required = false) Instant since + ) { + return ResponseEntity.ok(ApiResponse.ok("Ticket sync retrieved", syncService.syncTickets(since))); + } + + @GetMapping("/notifications") + @PreAuthorize("hasRole('STUDENT')") + public ResponseEntity>>> syncNotifications( + @RequestParam(required = false) Instant since + ) { + return ResponseEntity.ok(ApiResponse.ok("Notification sync retrieved", syncService.syncNotifications(since))); + } + + @GetMapping("/analytics") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity>> syncAnalytics( + @RequestParam(required = false) Instant since + ) { + return ResponseEntity.ok(ApiResponse.ok("Analytics sync retrieved", syncService.syncAnalytics(since))); + } +} diff --git a/src/main/java/com/ucms_backend/dto/RealtimeEventResponse.java b/src/main/java/com/ucms_backend/dto/RealtimeEventResponse.java new file mode 100644 index 0000000..3df05dd --- /dev/null +++ b/src/main/java/com/ucms_backend/dto/RealtimeEventResponse.java @@ -0,0 +1,19 @@ +package com.ucms_backend.dto; + +import java.time.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class RealtimeEventResponse { + private String domain; + private String eventType; + private String entityId; + private LocalDateTime updatedAt; + private String actorRole; +} diff --git a/src/main/java/com/ucms_backend/dto/SyncResponse.java b/src/main/java/com/ucms_backend/dto/SyncResponse.java new file mode 100644 index 0000000..cd08216 --- /dev/null +++ b/src/main/java/com/ucms_backend/dto/SyncResponse.java @@ -0,0 +1,19 @@ +package com.ucms_backend.dto; + +import java.time.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SyncResponse { + + private T items; + private boolean fullRefresh; + private LocalDateTime serverTime; + private LocalDateTime nextSince; +} diff --git a/src/main/java/com/ucms_backend/repository/ProfileRepository.java b/src/main/java/com/ucms_backend/repository/ProfileRepository.java index 40ecbbb..bc5d132 100644 --- a/src/main/java/com/ucms_backend/repository/ProfileRepository.java +++ b/src/main/java/com/ucms_backend/repository/ProfileRepository.java @@ -1,6 +1,7 @@ package com.ucms_backend.repository; import com.ucms_backend.model.entity.Profile; +import java.util.List; import java.util.Optional; import java.util.UUID; import org.springframework.data.jpa.repository.JpaRepository; @@ -11,4 +12,6 @@ public interface ProfileRepository extends JpaRepository { Optional findByStudentId(String studentId); + + List findByRole(String role); } diff --git a/src/main/java/com/ucms_backend/service/NotificationService.java b/src/main/java/com/ucms_backend/service/NotificationService.java index c5dcd20..b9293ef 100644 --- a/src/main/java/com/ucms_backend/service/NotificationService.java +++ b/src/main/java/com/ucms_backend/service/NotificationService.java @@ -1,8 +1,11 @@ package com.ucms_backend.service; import com.ucms_backend.dto.NotificationResponse; +import com.ucms_backend.dto.RealtimeEventResponse; import com.ucms_backend.exception.AppException; import com.ucms_backend.model.entity.Notification; +import java.time.LocalDateTime; +import java.time.ZoneOffset; import com.ucms_backend.repository.NotificationRepository; import java.util.List; import java.util.UUID; @@ -12,9 +15,11 @@ public class NotificationService { private final NotificationRepository notificationRepository; + private final RealtimeSseService realtimeSseService; - public NotificationService(NotificationRepository notificationRepository) { + public NotificationService(NotificationRepository notificationRepository, RealtimeSseService realtimeSseService) { this.notificationRepository = notificationRepository; + this.realtimeSseService = realtimeSseService; } public List getNotifications(UUID userId) { @@ -29,6 +34,7 @@ public NotificationResponse markAsRead(Long id, UUID userId) { notification.setRead(true); Notification saved = notificationRepository.save(notification); + publishNotificationEvent(userId, saved.getId(), "NOTIFICATION_READ", "STUDENT"); return NotificationResponse.from(saved); } @@ -36,6 +42,7 @@ public void markAllAsRead(UUID userId) { List notifications = notificationRepository.findByUserIdOrderByCreatedAtDesc(userId); notifications.forEach(n -> n.setRead(true)); notificationRepository.saveAll(notifications); + publishNotificationEvent(userId, null, "NOTIFICATIONS_READ_ALL", "STUDENT"); } public void createNotification(UUID userId, Long ticketId, String message) { @@ -44,6 +51,17 @@ public void createNotification(UUID userId, Long ticketId, String message) { .ticketId(ticketId) .message(message) .build(); - notificationRepository.save(notification); + Notification saved = notificationRepository.save(notification); + publishNotificationEvent(userId, saved.getId(), "NOTIFICATION_CREATED", "SYSTEM"); + } + + private void publishNotificationEvent(UUID userId, Long notificationId, String eventType, String actorRole) { + realtimeSseService.publishToUser(userId, RealtimeEventResponse.builder() + .domain("notifications") + .eventType(eventType) + .entityId(notificationId != null ? String.valueOf(notificationId) : userId.toString()) + .updatedAt(LocalDateTime.now(ZoneOffset.UTC)) + .actorRole(actorRole) + .build()); } } diff --git a/src/main/java/com/ucms_backend/service/ProfileService.java b/src/main/java/com/ucms_backend/service/ProfileService.java index 19353ce..1bf6058 100644 --- a/src/main/java/com/ucms_backend/service/ProfileService.java +++ b/src/main/java/com/ucms_backend/service/ProfileService.java @@ -1,12 +1,15 @@ package com.ucms_backend.service; import com.ucms_backend.dto.ProfileResponse; +import com.ucms_backend.dto.RealtimeEventResponse; import com.ucms_backend.dto.UpdateEmailRequest; import com.ucms_backend.dto.UpdateProfileRequest; import com.ucms_backend.exception.AppException; import com.ucms_backend.model.entity.Profile; import com.ucms_backend.repository.ProfileRepository; import java.util.UUID; +import java.time.LocalDateTime; +import java.time.ZoneOffset; import org.springframework.stereotype.Service; @Service @@ -14,10 +17,16 @@ public class ProfileService { private final ProfileRepository profileRepository; private final SupabaseAuthService supabaseAuthService; + private final RealtimeSseService realtimeSseService; - public ProfileService(ProfileRepository profileRepository, SupabaseAuthService supabaseAuthService) { + public ProfileService( + ProfileRepository profileRepository, + SupabaseAuthService supabaseAuthService, + RealtimeSseService realtimeSseService + ) { this.profileRepository = profileRepository; this.supabaseAuthService = supabaseAuthService; + this.realtimeSseService = realtimeSseService; } public ProfileResponse getMyProfile(UUID authUserId) { @@ -37,6 +46,7 @@ public ProfileResponse updateProfile(UUID authUserId, UpdateProfileRequest reque profile.setYearLevel(request.getYearLevel()); Profile saved = profileRepository.save(profile); + publishProfileEvent(saved.getAuthUserId(), "PROFILE_UPDATED"); return ProfileResponse.from(saved); } @@ -51,6 +61,7 @@ public ProfileResponse updateEmail(UUID authUserId, UpdateEmailRequest request) Profile saved = profileRepository.save(profile); supabaseAuthService.sendVerificationEmail(authUserId, request.getEmail()); + publishProfileEvent(saved.getAuthUserId(), "PROFILE_EMAIL_UPDATED"); return ProfileResponse.from(saved); } @@ -66,9 +77,20 @@ public ProfileResponse confirmEmailVerified(UUID authUserId) { profile.setEmailVerified(true); Profile saved = profileRepository.save(profile); + publishProfileEvent(saved.getAuthUserId(), "PROFILE_EMAIL_VERIFIED"); return ProfileResponse.from(saved); } + private void publishProfileEvent(UUID userId, String eventType) { + realtimeSseService.publishToUser(userId, RealtimeEventResponse.builder() + .domain("profile") + .eventType(eventType) + .entityId(userId.toString()) + .updatedAt(LocalDateTime.now(ZoneOffset.UTC)) + .actorRole("SYSTEM") + .build()); + } + private void ensureOwner(Profile profile, UUID authUserId) { if (!profile.getAuthUserId().equals(authUserId)) { throw new AppException(403, "FORBIDDEN", "Access denied"); diff --git a/src/main/java/com/ucms_backend/service/RealtimeSseService.java b/src/main/java/com/ucms_backend/service/RealtimeSseService.java new file mode 100644 index 0000000..6d7178f --- /dev/null +++ b/src/main/java/com/ucms_backend/service/RealtimeSseService.java @@ -0,0 +1,89 @@ +package com.ucms_backend.service; + +import com.ucms_backend.dto.RealtimeEventResponse; +import com.ucms_backend.model.entity.Profile; +import com.ucms_backend.repository.ProfileRepository; +import java.io.IOException; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import org.springframework.stereotype.Service; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +@Service +public class RealtimeSseService { + + private static final long SSE_TIMEOUT_MS = 0L; + private static final String ROLE_ADMIN = "ADMIN"; + + private final ProfileRepository profileRepository; + private final Map> emittersByUser = new ConcurrentHashMap<>(); + + public RealtimeSseService(ProfileRepository profileRepository) { + this.profileRepository = profileRepository; + } + + public SseEmitter subscribe(UUID userId) { + SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MS); + emittersByUser.computeIfAbsent(userId, key -> new CopyOnWriteArrayList<>()).add(emitter); + + emitter.onCompletion(() -> removeEmitter(userId, emitter)); + emitter.onTimeout(() -> removeEmitter(userId, emitter)); + emitter.onError(ex -> removeEmitter(userId, emitter)); + + sendToEmitter(userId, emitter, RealtimeEventResponse.builder() + .domain("system") + .eventType("CONNECTED") + .entityId(userId.toString()) + .updatedAt(LocalDateTime.now(ZoneOffset.UTC)) + .actorRole("SYSTEM") + .build()); + + return emitter; + } + + public void publishToUser(UUID userId, RealtimeEventResponse event) { + List emitters = emittersByUser.get(userId); + if (emitters == null || emitters.isEmpty()) { + return; + } + + for (SseEmitter emitter : emitters) { + sendToEmitter(userId, emitter, event); + } + } + + public void publishToAdmins(RealtimeEventResponse event) { + List admins = profileRepository.findByRole(ROLE_ADMIN); + for (Profile admin : admins) { + if (admin.getAuthUserId() != null) { + publishToUser(admin.getAuthUserId(), event); + } + } + } + + private void sendToEmitter(UUID userId, SseEmitter emitter, RealtimeEventResponse event) { + try { + emitter.send(SseEmitter.event() + .name("sync") + .data(event)); + } catch (IOException ex) { + removeEmitter(userId, emitter); + } + } + + private void removeEmitter(UUID userId, SseEmitter emitter) { + List emitters = emittersByUser.get(userId); + if (emitters == null) { + return; + } + emitters.remove(emitter); + if (emitters.isEmpty()) { + emittersByUser.remove(userId); + } + } +} diff --git a/src/main/java/com/ucms_backend/service/SyncService.java b/src/main/java/com/ucms_backend/service/SyncService.java new file mode 100644 index 0000000..b44b118 --- /dev/null +++ b/src/main/java/com/ucms_backend/service/SyncService.java @@ -0,0 +1,89 @@ +package com.ucms_backend.service; + +import com.ucms_backend.dto.AnalyticsOverviewResponse; +import com.ucms_backend.dto.NotificationResponse; +import com.ucms_backend.dto.ProfileResponse; +import com.ucms_backend.dto.SyncResponse; +import com.ucms_backend.dto.TicketResponse; +import com.ucms_backend.security.SecurityUtils; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.UUID; +import org.springframework.stereotype.Service; + +@Service +public class SyncService { + + private final ProfileService profileService; + private final TicketService ticketService; + private final NotificationService notificationService; + private final AnalyticsService analyticsService; + + public SyncService( + ProfileService profileService, + TicketService ticketService, + NotificationService notificationService, + AnalyticsService analyticsService + ) { + this.profileService = profileService; + this.ticketService = ticketService; + this.notificationService = notificationService; + this.analyticsService = analyticsService; + } + + public SyncResponse syncProfile(Instant since) { + UUID userId = SecurityUtils.getCurrentUserId(); + ProfileResponse profile = profileService.getMyProfile(userId); + LocalDateTime now = utcNow(); + + return SyncResponse.builder() + .items(profile) + .fullRefresh(true) + .serverTime(now) + .nextSince(now) + .build(); + } + + public SyncResponse> syncTickets(Instant since) { + List tickets = ticketService.getTicketsSince(since, false); + LocalDateTime now = utcNow(); + + return SyncResponse.>builder() + .items(tickets) + .fullRefresh(since == null) + .serverTime(now) + .nextSince(now) + .build(); + } + + public SyncResponse> syncNotifications(Instant since) { + UUID userId = SecurityUtils.getCurrentUserId(); + List notifications = notificationService.getNotifications(userId); + LocalDateTime now = utcNow(); + + return SyncResponse.>builder() + .items(notifications) + .fullRefresh(true) + .serverTime(now) + .nextSince(now) + .build(); + } + + public SyncResponse syncAnalytics(Instant since) { + AnalyticsOverviewResponse overview = analyticsService.getOverview(); + LocalDateTime now = utcNow(); + + return SyncResponse.builder() + .items(overview) + .fullRefresh(true) + .serverTime(now) + .nextSince(now) + .build(); + } + + private LocalDateTime utcNow() { + return LocalDateTime.now(ZoneOffset.UTC); + } +} diff --git a/src/main/java/com/ucms_backend/service/TicketResponseService.java b/src/main/java/com/ucms_backend/service/TicketResponseService.java index aa42e66..7592285 100644 --- a/src/main/java/com/ucms_backend/service/TicketResponseService.java +++ b/src/main/java/com/ucms_backend/service/TicketResponseService.java @@ -1,6 +1,7 @@ package com.ucms_backend.service; import com.ucms_backend.dto.CreateResponseRequest; +import com.ucms_backend.dto.RealtimeEventResponse; import com.ucms_backend.dto.TicketResponseDto; import com.ucms_backend.exception.AppException; import com.ucms_backend.model.entity.Ticket; @@ -10,6 +11,8 @@ import com.ucms_backend.security.SecurityUtils; import java.util.List; import java.util.UUID; +import java.time.LocalDateTime; +import java.time.ZoneOffset; import org.springframework.stereotype.Service; @Service @@ -18,15 +21,18 @@ public class TicketResponseService { private final TicketRepository ticketRepository; private final TicketResponseRepository ticketResponseRepository; private final NotificationService notificationService; + private final RealtimeSseService realtimeSseService; public TicketResponseService( TicketRepository ticketRepository, TicketResponseRepository ticketResponseRepository, - NotificationService notificationService + NotificationService notificationService, + RealtimeSseService realtimeSseService ) { this.ticketRepository = ticketRepository; this.ticketResponseRepository = ticketResponseRepository; this.notificationService = notificationService; + this.realtimeSseService = realtimeSseService; } public TicketResponseDto addResponse(Long ticketId, CreateResponseRequest request) { @@ -50,6 +56,16 @@ public TicketResponseDto addResponse(Long ticketId, CreateResponseRequest reques "Admin posted a response to your ticket #" + ticket.getTicketNumber() ); + RealtimeEventResponse event = RealtimeEventResponse.builder() + .domain("tickets") + .eventType("TICKET_RESPONSE_CREATED") + .entityId(String.valueOf(ticket.getId())) + .updatedAt(LocalDateTime.now(ZoneOffset.UTC)) + .actorRole("ADMIN") + .build(); + realtimeSseService.publishToUser(ticket.getUserId(), event); + realtimeSseService.publishToAdmins(event); + return TicketResponseDto.from(saved); } diff --git a/src/main/java/com/ucms_backend/service/TicketService.java b/src/main/java/com/ucms_backend/service/TicketService.java index 9a2faa0..d2f3c15 100644 --- a/src/main/java/com/ucms_backend/service/TicketService.java +++ b/src/main/java/com/ucms_backend/service/TicketService.java @@ -1,6 +1,7 @@ package com.ucms_backend.service; import com.ucms_backend.dto.CreateTicketRequest; +import com.ucms_backend.dto.RealtimeEventResponse; import com.ucms_backend.dto.TicketResponse; import com.ucms_backend.dto.UrgencyOverrideRequest; import com.ucms_backend.dto.UpdateStatusRequest; @@ -16,6 +17,7 @@ import com.ucms_backend.security.SecurityUtils; import java.time.LocalDateTime; import java.time.ZoneOffset; +import java.time.Instant; import java.util.HashSet; import java.util.List; import java.util.Locale; @@ -41,6 +43,7 @@ public class TicketService { private final TicketNumberGenerator ticketNumberGenerator; private final NotificationService notificationService; private final TicketUrgencyScoringService ticketUrgencyScoringService; + private final RealtimeSseService realtimeSseService; public TicketService( TicketRepository ticketRepository, @@ -49,7 +52,8 @@ public TicketService( TicketResponseRepository ticketResponseRepository, TicketNumberGenerator ticketNumberGenerator, NotificationService notificationService, - TicketUrgencyScoringService ticketUrgencyScoringService + TicketUrgencyScoringService ticketUrgencyScoringService, + RealtimeSseService realtimeSseService ) { this.ticketRepository = ticketRepository; this.categoryRepository = categoryRepository; @@ -58,6 +62,7 @@ public TicketService( this.ticketNumberGenerator = ticketNumberGenerator; this.notificationService = notificationService; this.ticketUrgencyScoringService = ticketUrgencyScoringService; + this.realtimeSseService = realtimeSseService; } private String resolveCategoryName(Long categoryId) { @@ -184,6 +189,7 @@ public TicketResponse createTicket(CreateTicketRequest request) { applyUrgency(ticket); Ticket saved = ticketRepository.save(ticket); + publishTicketEvent(saved, "TICKET_CREATED", "STUDENT"); return toResponse(saved); } @@ -227,6 +233,49 @@ public List getTickets(String status, Long categoryId, boolean i return List.of(); } + public List getTicketsSince(Instant since, boolean includeArchived) { + UUID userId = SecurityUtils.getCurrentUserId(); + String role = SecurityUtils.getCurrentRole(); + LocalDateTime sinceUtc = since != null ? LocalDateTime.ofInstant(since, ZoneOffset.UTC) : null; + + if ("STUDENT".equals(role)) { + List tickets = includeArchived + ? ticketRepository.findByUserId(userId) + : ticketRepository.findByUserIdAndArchivedFalse(userId); + + if (sinceUtc != null) { + tickets = tickets.stream() + .filter(ticket -> ticket.getUpdatedAt() != null && !ticket.getUpdatedAt().isBefore(sinceUtc)) + .toList(); + } + + Set ticketIdsWithResponses = findTicketIdsWithResponses(tickets); + return tickets.stream() + .map(ticket -> toResponse(ticket, ticketIdsWithResponses)) + .toList(); + } + + if ("ADMIN".equals(role)) { + Specification spec = (root, query, cb) -> cb.conjunction(); + + if (!includeArchived) { + spec = spec.and((root, query, cb) -> cb.isFalse(root.get("archived"))); + } + + if (sinceUtc != null) { + spec = spec.and((root, query, cb) -> cb.greaterThanOrEqualTo(root.get("updatedAt"), sinceUtc)); + } + + List tickets = ticketRepository.findAll(spec); + Set ticketIdsWithResponses = findTicketIdsWithResponses(tickets); + return tickets.stream() + .map(ticket -> toResponse(ticket, ticketIdsWithResponses)) + .toList(); + } + + return List.of(); + } + public TicketResponse getTicketById(Long id) { UUID userId = SecurityUtils.getCurrentUserId(); String role = SecurityUtils.getCurrentRole(); @@ -265,6 +314,7 @@ public TicketResponse confirmResolved(Long ticketId) { ticket.setArchived(true); applyUrgency(ticket); Ticket saved = ticketRepository.save(ticket); + publishTicketEvent(saved, "TICKET_CONFIRMED_RESOLVED", "STUDENT"); notificationService.createNotification( saved.getUserId(), @@ -308,6 +358,7 @@ public TicketResponse updateStatus(Long id, UpdateStatusRequest request) { } applyUrgency(ticket); Ticket saved = ticketRepository.save(ticket); + publishTicketEvent(saved, "TICKET_STATUS_UPDATED", "ADMIN"); notificationService.createNotification( saved.getUserId(), @@ -317,4 +368,17 @@ public TicketResponse updateStatus(Long id, UpdateStatusRequest request) { return toResponse(saved); } + + private void publishTicketEvent(Ticket ticket, String eventType, String actorRole) { + RealtimeEventResponse event = RealtimeEventResponse.builder() + .domain("tickets") + .eventType(eventType) + .entityId(String.valueOf(ticket.getId())) + .updatedAt(ticket.getUpdatedAt()) + .actorRole(actorRole) + .build(); + + realtimeSseService.publishToUser(ticket.getUserId(), event); + realtimeSseService.publishToAdmins(event); + } }