-
Notifications
You must be signed in to change notification settings - Fork 0
Issue/71 (DONT MERGE) #73
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| package org.example.projektarendehantering.application.service; | ||
|
|
||
| import java.time.Instant; | ||
| import java.util.UUID; | ||
|
|
||
| public record CaseRealtimeEvent( | ||
| String eventType, | ||
| UUID caseId, | ||
| Instant occurredAt, | ||
| String message | ||
| ) { | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,179 @@ | ||||||||||||||||||||||||||||||||||||||||
| package org.example.projektarendehantering.application.service; | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| import lombok.RequiredArgsConstructor; | ||||||||||||||||||||||||||||||||||||||||
| import lombok.extern.slf4j.Slf4j; | ||||||||||||||||||||||||||||||||||||||||
| import org.example.projektarendehantering.common.Actor; | ||||||||||||||||||||||||||||||||||||||||
| import org.example.projektarendehantering.common.CaseStatus; | ||||||||||||||||||||||||||||||||||||||||
| import org.example.projektarendehantering.common.NotAuthorizedException; | ||||||||||||||||||||||||||||||||||||||||
| import org.example.projektarendehantering.common.Role; | ||||||||||||||||||||||||||||||||||||||||
| import org.example.projektarendehantering.infrastructure.persistence.CaseEntity; | ||||||||||||||||||||||||||||||||||||||||
| import org.example.projektarendehantering.infrastructure.persistence.CaseRepository; | ||||||||||||||||||||||||||||||||||||||||
| import org.springframework.scheduling.annotation.Scheduled; | ||||||||||||||||||||||||||||||||||||||||
| import org.springframework.stereotype.Service; | ||||||||||||||||||||||||||||||||||||||||
| import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| import java.io.IOException; | ||||||||||||||||||||||||||||||||||||||||
| import java.time.Instant; | ||||||||||||||||||||||||||||||||||||||||
| import java.util.Map; | ||||||||||||||||||||||||||||||||||||||||
| import java.util.UUID; | ||||||||||||||||||||||||||||||||||||||||
| import java.util.concurrent.ConcurrentHashMap; | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| @Slf4j | ||||||||||||||||||||||||||||||||||||||||
| @Service | ||||||||||||||||||||||||||||||||||||||||
| @RequiredArgsConstructor | ||||||||||||||||||||||||||||||||||||||||
| public class CaseRealtimeService { | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| private static final long EMITTER_TIMEOUT_MS = 15 * 60 * 1000L; | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| private final CaseRepository caseRepository; | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| private final Map<UUID, ListSubscription> listSubscribers = new ConcurrentHashMap<>(); | ||||||||||||||||||||||||||||||||||||||||
| private final Map<UUID, CaseSubscription> caseSubscribers = new ConcurrentHashMap<>(); | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| public SseEmitter subscribeToCaseList(Actor actor) { | ||||||||||||||||||||||||||||||||||||||||
| if (actor == null || actor.userId() == null) { | ||||||||||||||||||||||||||||||||||||||||
| throw new NotAuthorizedException("Missing actor"); | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| UUID subscriptionId = UUID.randomUUID(); | ||||||||||||||||||||||||||||||||||||||||
| SseEmitter emitter = new SseEmitter(EMITTER_TIMEOUT_MS); | ||||||||||||||||||||||||||||||||||||||||
| listSubscribers.put(subscriptionId, new ListSubscription(actor, emitter)); | ||||||||||||||||||||||||||||||||||||||||
| bindLifecycle(subscriptionId, emitter, true); | ||||||||||||||||||||||||||||||||||||||||
| sendEvent(emitter, "connected", new CaseRealtimeEvent("connected", null, Instant.now(), "list-stream")); | ||||||||||||||||||||||||||||||||||||||||
| return emitter; | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| public SseEmitter subscribeToCase(Actor actor, UUID caseId) { | ||||||||||||||||||||||||||||||||||||||||
| if (actor == null || actor.userId() == null) { | ||||||||||||||||||||||||||||||||||||||||
| throw new NotAuthorizedException("Missing actor"); | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| if (caseId == null) { | ||||||||||||||||||||||||||||||||||||||||
| throw new IllegalArgumentException("caseId is required"); | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| CaseEntity caseEntity = caseRepository.findById(caseId) | ||||||||||||||||||||||||||||||||||||||||
| .orElseThrow(() -> new IllegalArgumentException("Case not found")); | ||||||||||||||||||||||||||||||||||||||||
| if (!canRead(actor, caseEntity)) { | ||||||||||||||||||||||||||||||||||||||||
| throw new NotAuthorizedException("Not allowed to subscribe to this case"); | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| UUID subscriptionId = UUID.randomUUID(); | ||||||||||||||||||||||||||||||||||||||||
| SseEmitter emitter = new SseEmitter(EMITTER_TIMEOUT_MS); | ||||||||||||||||||||||||||||||||||||||||
| caseSubscribers.put(subscriptionId, new CaseSubscription(actor, caseId, emitter)); | ||||||||||||||||||||||||||||||||||||||||
| bindLifecycle(subscriptionId, emitter, false); | ||||||||||||||||||||||||||||||||||||||||
| sendEvent(emitter, "connected", new CaseRealtimeEvent("connected", caseId, Instant.now(), "case-stream")); | ||||||||||||||||||||||||||||||||||||||||
| return emitter; | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| public void publishCaseEvent(UUID caseId, String eventType, String message) { | ||||||||||||||||||||||||||||||||||||||||
| CaseRealtimeEvent event = new CaseRealtimeEvent(eventType, caseId, Instant.now(), message); | ||||||||||||||||||||||||||||||||||||||||
| broadcastCaseEvent(event); | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| @Scheduled(fixedDelay = 25000L) | ||||||||||||||||||||||||||||||||||||||||
| public void keepAlive() { | ||||||||||||||||||||||||||||||||||||||||
| CaseRealtimeEvent heartbeat = new CaseRealtimeEvent("keepalive", null, Instant.now(), "heartbeat"); | ||||||||||||||||||||||||||||||||||||||||
| listSubscribers.forEach((id, sub) -> { | ||||||||||||||||||||||||||||||||||||||||
| if (!sendEvent(sub.emitter(), "keepalive", heartbeat)) { | ||||||||||||||||||||||||||||||||||||||||
| listSubscribers.remove(id); | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||
| caseSubscribers.forEach((id, sub) -> { | ||||||||||||||||||||||||||||||||||||||||
| CaseRealtimeEvent caseHeartbeat = new CaseRealtimeEvent("keepalive", sub.caseId(), Instant.now(), "heartbeat"); | ||||||||||||||||||||||||||||||||||||||||
| if (!sendEvent(sub.emitter(), "keepalive", caseHeartbeat)) { | ||||||||||||||||||||||||||||||||||||||||
| caseSubscribers.remove(id); | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| private void broadcastCaseEvent(CaseRealtimeEvent event) { | ||||||||||||||||||||||||||||||||||||||||
| if (event.caseId() == null) { | ||||||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| CaseEntity caseEntity = caseRepository.findById(event.caseId()).orElse(null); | ||||||||||||||||||||||||||||||||||||||||
| if (caseEntity == null) { | ||||||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| listSubscribers.forEach((id, sub) -> { | ||||||||||||||||||||||||||||||||||||||||
| if (!canRead(sub.actor(), caseEntity)) { | ||||||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| if (!sendEvent(sub.emitter(), event.eventType(), event)) { | ||||||||||||||||||||||||||||||||||||||||
| listSubscribers.remove(id); | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| caseSubscribers.forEach((id, sub) -> { | ||||||||||||||||||||||||||||||||||||||||
| if (!event.caseId().equals(sub.caseId())) { | ||||||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| if (!canRead(sub.actor(), caseEntity)) { | ||||||||||||||||||||||||||||||||||||||||
| caseSubscribers.remove(id); | ||||||||||||||||||||||||||||||||||||||||
| closeEmitter(sub.emitter()); | ||||||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| if (!sendEvent(sub.emitter(), event.eventType(), event)) { | ||||||||||||||||||||||||||||||||||||||||
| caseSubscribers.remove(id); | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| private boolean canRead(Actor actor, CaseEntity entity) { | ||||||||||||||||||||||||||||||||||||||||
| if (actor == null || actor.role() == null) { | ||||||||||||||||||||||||||||||||||||||||
| return false; | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| if (actor.role() == Role.MANAGER) { | ||||||||||||||||||||||||||||||||||||||||
| return true; | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| if (entity.getStatus() == CaseStatus.CLOSED) { | ||||||||||||||||||||||||||||||||||||||||
| return false; | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| if (actor.role() == Role.DOCTOR && actor.userId().equals(entity.getOwnerId())) { | ||||||||||||||||||||||||||||||||||||||||
| return true; | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| if (actor.role() == Role.NURSE && actor.userId().equals(entity.getHandlerId())) { | ||||||||||||||||||||||||||||||||||||||||
| return true; | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| return false; | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| private void bindLifecycle(UUID subscriptionId, SseEmitter emitter, boolean listStream) { | ||||||||||||||||||||||||||||||||||||||||
| emitter.onCompletion(() -> remove(subscriptionId, listStream)); | ||||||||||||||||||||||||||||||||||||||||
| emitter.onTimeout(() -> remove(subscriptionId, listStream)); | ||||||||||||||||||||||||||||||||||||||||
| emitter.onError(ex -> remove(subscriptionId, listStream)); | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| private void remove(UUID subscriptionId, boolean listStream) { | ||||||||||||||||||||||||||||||||||||||||
| if (listStream) { | ||||||||||||||||||||||||||||||||||||||||
| listSubscribers.remove(subscriptionId); | ||||||||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||||||||
| caseSubscribers.remove(subscriptionId); | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| private boolean sendEvent(SseEmitter emitter, String eventName, CaseRealtimeEvent payload) { | ||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||
| emitter.send(SseEmitter.event().name(eventName).data(payload)); | ||||||||||||||||||||||||||||||||||||||||
| return true; | ||||||||||||||||||||||||||||||||||||||||
| } catch (IOException ex) { | ||||||||||||||||||||||||||||||||||||||||
| log.debug("Failed to send SSE event '{}': {}", eventName, ex.getMessage()); | ||||||||||||||||||||||||||||||||||||||||
| closeEmitter(emitter); | ||||||||||||||||||||||||||||||||||||||||
| return false; | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+155
to
+163
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🌐 Web query:
💡 Result: Yes, SseEmitter.send(...) can throw IllegalStateException when the emitter is already completed. The SseEmitter.send method explicitly checks if it is already complete and throws IllegalStateException if so, as shown in the source code: Assert.state(!this.complete, "ResponseBodyEmitter has already completed" + (this.failure != null ? " with error: " + this.failure : ""));. Multiple Stack Overflow examples confirm this behavior in practice, such as when sending after timeout or completion, resulting in "ResponseBodyEmitter is already set complete". For disconnection, it typically throws IOException rather than IllegalStateException. Citations:
Catch
Handle completed/disconnected emitters defensively- } catch (IOException ex) {
+ } catch (IOException | IllegalStateException ex) {
log.debug("Failed to send SSE event '{}': {}", eventName, ex.getMessage());
closeEmitter(emitter);
return false;
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| private void closeEmitter(SseEmitter emitter) { | ||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||
| emitter.complete(); | ||||||||||||||||||||||||||||||||||||||||
| } catch (RuntimeException ignored) { | ||||||||||||||||||||||||||||||||||||||||
| // no-op | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| private record ListSubscription(Actor actor, SseEmitter emitter) { | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| private record CaseSubscription(Actor actor, UUID caseId, SseEmitter emitter) { | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -41,6 +41,7 @@ public class CaseService { | |
| private final CaseNoteRepository caseNoteRepository; | ||
| private final EmployeeRepository employeeRepository; | ||
| private final AuditService auditService; | ||
| private final CaseRealtimeService caseRealtimeService; | ||
|
|
||
| @Transactional | ||
| public void addNote(UUID caseId, String content, Actor actor) { | ||
|
|
@@ -64,6 +65,7 @@ public void addNote(UUID caseId, String content, Actor actor) { | |
| caseEntity.setStatus(CaseStatus.COMMUNICATION); | ||
| caseRepository.save(caseEntity); | ||
| recordStatusChange(actor, caseEntity.getId(), previousStatus, CaseStatus.COMMUNICATION); | ||
| caseRealtimeService.publishCaseEvent(caseEntity.getId(), "note-added", "A note was added"); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Defer case realtime events until after commit. These methods are transactional, but the SSE events are emitted before commit. A failed commit can produce phantom notifications, and a fast client reload can observe stale state. Use an after-commit helper for all case events import org.springframework.transaction.annotation.Transactional;
+import org.springframework.transaction.support.TransactionSynchronization;
+import org.springframework.transaction.support.TransactionSynchronizationManager;- caseRealtimeService.publishCaseEvent(caseEntity.getId(), "note-added", "A note was added");
+ publishCaseEventAfterCommit(caseEntity.getId(), "note-added", "A note was added");- caseRealtimeService.publishCaseEvent(savedEntity.getId(), "case-created", "Case created");
+ publishCaseEventAfterCommit(savedEntity.getId(), "case-created", "Case created");- caseRealtimeService.publishCaseEvent(savedEntity.getId(), "case-updated", "Case updated");
+ publishCaseEventAfterCommit(savedEntity.getId(), "case-updated", "Case updated");- caseRealtimeService.publishCaseEvent(entity.getId(), "status-changed", "Case closed");
+ publishCaseEventAfterCommit(entity.getId(), "status-changed", "Case closed");- caseRealtimeService.publishCaseEvent(savedEntity.getId(), "assignment-updated", "Case assignment updated");
+ publishCaseEventAfterCommit(savedEntity.getId(), "assignment-updated", "Case assignment updated");
return caseMapper.toDTO(savedEntity);
}
CaseEntity savedEntity = caseRepository.save(entity);
- caseRealtimeService.publishCaseEvent(savedEntity.getId(), "assignment-updated", "Case assignment updated");
+ publishCaseEventAfterCommit(savedEntity.getId(), "assignment-updated", "Case assignment updated");
return caseMapper.toDTO(savedEntity);+ private void publishCaseEventAfterCommit(UUID caseId, String eventType, String message) {
+ if (TransactionSynchronizationManager.isSynchronizationActive()) {
+ TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+ `@Override`
+ public void afterCommit() {
+ caseRealtimeService.publishCaseEvent(caseId, eventType, message);
+ }
+ });
+ return;
+ }
+ caseRealtimeService.publishCaseEvent(caseId, eventType, message);
+ }
+
private void recordStatusChange(Actor actor, UUID caseId, CaseStatus from, CaseStatus to) {Also applies to: 110-110, 147-147, 164-164, 258-263 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| @Transactional | ||
|
|
@@ -105,6 +107,7 @@ public CaseDTO createCase(Actor actor, CaseDTO caseDTO) { | |
| } | ||
| CaseEntity savedEntity = caseRepository.save(entity); | ||
| recordStatusChange(actor, savedEntity.getId(), null, CaseStatus.CREATED); | ||
| caseRealtimeService.publishCaseEvent(savedEntity.getId(), "case-created", "Case created"); | ||
| return caseMapper.toDTO(savedEntity); | ||
| } | ||
|
|
||
|
|
@@ -141,6 +144,7 @@ public CaseDTO updateCase(Actor actor, UUID caseId, CaseDTO caseDTO) { | |
|
|
||
| CaseEntity savedEntity = caseRepository.save(entity); | ||
| recordStatusChange(actor, savedEntity.getId(), previousStatus, CaseStatus.UPDATED); | ||
| caseRealtimeService.publishCaseEvent(savedEntity.getId(), "case-updated", "Case updated"); | ||
| return caseMapper.toDTO(savedEntity); | ||
| } | ||
|
|
||
|
|
@@ -157,6 +161,7 @@ public void deleteCase(Actor actor, UUID caseId) { | |
| entity.setStatus(CaseStatus.CLOSED); | ||
| caseRepository.save(entity); | ||
| recordStatusChange(actor, entity.getId(), previousStatus, CaseStatus.CLOSED); | ||
| caseRealtimeService.publishCaseEvent(entity.getId(), "status-changed", "Case closed"); | ||
| } | ||
|
|
||
| @Transactional(readOnly = true) | ||
|
|
@@ -250,10 +255,12 @@ public CaseDTO assignUsers(Actor actor, UUID caseId, CaseAssignmentDTO dto) { | |
| entity.setStatus(CaseStatus.ASSIGNED); | ||
| CaseEntity savedEntity = caseRepository.save(entity); | ||
| recordStatusChange(actor, savedEntity.getId(), previousStatus, CaseStatus.ASSIGNED); | ||
| caseRealtimeService.publishCaseEvent(savedEntity.getId(), "assignment-updated", "Case assignment updated"); | ||
| return caseMapper.toDTO(savedEntity); | ||
| } | ||
|
|
||
| return caseMapper.toDTO(caseRepository.save(entity)); | ||
| CaseEntity savedEntity = caseRepository.save(entity); | ||
| caseRealtimeService.publishCaseEvent(savedEntity.getId(), "assignment-updated", "Case assignment updated"); | ||
| return caseMapper.toDTO(savedEntity); | ||
| } | ||
|
|
||
| private UUID requireEmployeeWithRole(UUID id, Set<Role> allowedRoles, String fieldName) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -31,6 +31,7 @@ public class DocumentService { | |
| private final S3Template s3Template; | ||
| private final DocumentMapper documentMapper; | ||
| private final AuditService auditService; | ||
| private final CaseRealtimeService caseRealtimeService; | ||
|
|
||
| @Value("${app.s3.bucket}") | ||
| private String bucket; | ||
|
|
@@ -113,6 +114,7 @@ public void afterCompletion(int status) { | |
| .actorRole(actor.role() != null ? actor.role().name() : null) | ||
| .build()); | ||
| } | ||
| caseRealtimeService.publishCaseEvent(caseEntity.getId(), "document-added", "Document uploaded"); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Publish Line 117 sends the SSE event while Defer the notification to `afterCommit()`- caseRealtimeService.publishCaseEvent(caseEntity.getId(), "document-added", "Document uploaded");
+ if (syncActive) {
+ TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+ `@Override`
+ public void afterCommit() {
+ caseRealtimeService.publishCaseEvent(caseEntity.getId(), "document-added", "Document uploaded");
+ }
+ });
+ } else {
+ caseRealtimeService.publishCaseEvent(caseEntity.getId(), "document-added", "Document uploaded");
+ }
return documentMapper.toDTO(saved);🤖 Prompt for AI Agents |
||
| return documentMapper.toDTO(saved); | ||
| } catch (RuntimeException ex) { | ||
| if (!syncActive) { | ||
|
|
@@ -175,6 +177,7 @@ public void afterCommit() { | |
| // DB is already committed — log but do not throw | ||
| log.error("Failed to delete S3 object {} after DB commit", s3Key, e); | ||
| } | ||
| caseRealtimeService.publishCaseEvent(entity.getCaseEntity().getId(), "document-deleted", "Document deleted"); | ||
| } | ||
| } | ||
| ); | ||
|
|
@@ -185,6 +188,7 @@ public void afterCommit() { | |
| } catch (Exception e) { | ||
| log.error("Failed to delete S3 object {} (no active transaction)", s3Key, e); | ||
| } | ||
| caseRealtimeService.publishCaseEvent(entity.getCaseEntity().getId(), "document-deleted", "Document deleted"); | ||
| } | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| package org.example.projektarendehantering.presentation.web; | ||
|
|
||
| import lombok.RequiredArgsConstructor; | ||
| import org.example.projektarendehantering.application.service.CaseRealtimeService; | ||
| import org.example.projektarendehantering.common.Actor; | ||
| import org.example.projektarendehantering.infrastructure.security.SecurityActorAdapter; | ||
| import org.springframework.http.MediaType; | ||
| import org.springframework.web.bind.annotation.GetMapping; | ||
| import org.springframework.web.bind.annotation.PathVariable; | ||
| import org.springframework.web.bind.annotation.RequestMapping; | ||
| import org.springframework.web.bind.annotation.RestController; | ||
| import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; | ||
|
|
||
| import java.util.UUID; | ||
|
|
||
| @RestController | ||
| @RequestMapping("/ui/realtime/cases") | ||
| @RequiredArgsConstructor | ||
| public class CaseRealtimeController { | ||
|
|
||
| private final CaseRealtimeService caseRealtimeService; | ||
| private final SecurityActorAdapter securityActorAdapter; | ||
|
|
||
| @GetMapping(path = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) | ||
| public SseEmitter streamCaseList() { | ||
| Actor actor = securityActorAdapter.currentUser(); | ||
| return caseRealtimeService.subscribeToCaseList(actor); | ||
| } | ||
|
|
||
| @GetMapping(path = "/{caseId}/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) | ||
| public SseEmitter streamCase(@PathVariable UUID caseId) { | ||
| Actor actor = securityActorAdapter.currentUser(); | ||
| return caseRealtimeService.subscribeToCase(actor, caseId); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Align realtime access with case access rules and access-loss events.
canReadomits patients, so patients who can read their own cases throughCaseServicecannot subscribe or receive updates. Also, filtering only against the post-update state means users who lose access on close/reassignment are silently skipped instead of receiving the event needed to refresh/remove the stale case.Add patient visibility and handle revocation events explicitly
if (actor.role() == Role.NURSE && actor.userId().equals(entity.getHandlerId())) { return true; } + if (actor.role() == Role.PATIENT + && entity.getPatient() != null + && actor.userId().equals(entity.getPatient().getId())) { + return true; + } return false;For closures/reassignments, consider publishing with the previous readable audience or sending a minimal revocation/update event before removing case subscribers, so affected clients can reload cleanly.
🤖 Prompt for AI Agents