Skip to content
Merged
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
26 changes: 26 additions & 0 deletions src/main/java/com/ucms_backend/controller/RealtimeController.java
Original file line number Diff line number Diff line change
@@ -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());
}
}
60 changes: 60 additions & 0 deletions src/main/java/com/ucms_backend/controller/SyncController.java
Original file line number Diff line number Diff line change
@@ -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<ApiResponse<SyncResponse<ProfileResponse>>> 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<ApiResponse<SyncResponse<List<TicketResponse>>>> syncTickets(
@RequestParam(required = false) Instant since
) {
return ResponseEntity.ok(ApiResponse.ok("Ticket sync retrieved", syncService.syncTickets(since)));
}

@GetMapping("/notifications")
@PreAuthorize("hasRole('STUDENT')")
public ResponseEntity<ApiResponse<SyncResponse<List<NotificationResponse>>>> syncNotifications(
@RequestParam(required = false) Instant since
) {
return ResponseEntity.ok(ApiResponse.ok("Notification sync retrieved", syncService.syncNotifications(since)));
}

@GetMapping("/analytics")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<ApiResponse<SyncResponse<AnalyticsOverviewResponse>>> syncAnalytics(
@RequestParam(required = false) Instant since
) {
return ResponseEntity.ok(ApiResponse.ok("Analytics sync retrieved", syncService.syncAnalytics(since)));
}
}
19 changes: 19 additions & 0 deletions src/main/java/com/ucms_backend/dto/RealtimeEventResponse.java
Original file line number Diff line number Diff line change
@@ -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;
}
19 changes: 19 additions & 0 deletions src/main/java/com/ucms_backend/dto/SyncResponse.java
Original file line number Diff line number Diff line change
@@ -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<T> {

private T items;
private boolean fullRefresh;
private LocalDateTime serverTime;
private LocalDateTime nextSince;
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -11,4 +12,6 @@
public interface ProfileRepository extends JpaRepository<Profile, UUID> {

Optional<Profile> findByStudentId(String studentId);

List<Profile> findByRole(String role);
}
22 changes: 20 additions & 2 deletions src/main/java/com/ucms_backend/service/NotificationService.java
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<NotificationResponse> getNotifications(UUID userId) {
Expand All @@ -29,13 +34,15 @@ 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);
}

public void markAllAsRead(UUID userId) {
List<Notification> 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) {
Expand All @@ -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());
}
}
24 changes: 23 additions & 1 deletion src/main/java/com/ucms_backend/service/ProfileService.java
Original file line number Diff line number Diff line change
@@ -1,23 +1,32 @@
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
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) {
Expand All @@ -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);
}

Expand All @@ -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);
}

Expand All @@ -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");
Expand Down
89 changes: 89 additions & 0 deletions src/main/java/com/ucms_backend/service/RealtimeSseService.java
Original file line number Diff line number Diff line change
@@ -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<UUID, CopyOnWriteArrayList<SseEmitter>> 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<SseEmitter> emitters = emittersByUser.get(userId);
if (emitters == null || emitters.isEmpty()) {
return;
}

for (SseEmitter emitter : emitters) {
sendToEmitter(userId, emitter, event);
}
}

public void publishToAdmins(RealtimeEventResponse event) {
List<Profile> 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<SseEmitter> emitters = emittersByUser.get(userId);
if (emitters == null) {
return;
}
emitters.remove(emitter);
if (emitters.isEmpty()) {
emittersByUser.remove(userId);
}
}
}
Loading
Loading