Skip to content
Closed
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
23 changes: 14 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ Spring Boot application for case management with a Thymeleaf UI, PostgreSQL, and

## Start the application (local development)

<<<<<<< issue/69
### Quick start (recommended on Windows)

Run one command to perform all startup steps:
Expand All @@ -30,8 +29,20 @@ The script will:
- Start infrastructure with `docker compose up -d`
- Start Spring Boot with profile `local`

=======
>>>>>>> main
### Quick start (macOS/Linux with PowerShell)

Run the same startup script through `pwsh`:

```bash
pwsh ./start-local.ps1
```

Show full command output instead of fun/quiet mode:

```bash
pwsh ./start-local.ps1 --serious
```

### 0) Verify Docker is running (Windows)

Before running `docker compose up -d`, make sure Docker Desktop is started and the Linux engine is running.
Expand Down Expand Up @@ -134,14 +145,8 @@ docker compose up -d
```

- If you want to keep your local PostgreSQL running, map Docker PostgreSQL to another host port:
<<<<<<< issue/69
1. Edit `docker-compose.yml` PostgreSQL ports from `"5432:5432"` to `"5433:5432"`.
2. Edit `src/main/resources/application.properties` database URL from `localhost:5432` to `localhost:5433`.
3. Run `docker compose up -d` again.
=======
1. Edit `docker-compose.yml` PostgreSQL ports from `"5432:5432"` to `"5433:5432"`.
2. Edit `src/main/resources/application.properties` database URL from `localhost:5432` to `localhost:5433`.
3. Run `docker compose up -d` again.
>>>>>>> main
- App starts but login fails:
- Confirm you started Spring with profile `local` so `data-local.sql` is loaded.
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;

@SpringBootApplication
@EnableScheduling
public class ProjektArendehanteringApplication {

public static void main(String[] args) {
Expand Down
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;
Comment on lines +98 to +138

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Align realtime access with case access rules and access-loss events.

canRead omits patients, so patients who can read their own cases through CaseService cannot 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
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/org/example/projektarendehantering/application/service/CaseRealtimeService.java`
around lines 98 - 138, canRead currently omits patients and only checks the
post-update CaseEntity, so patients who should see their own cases can't
subscribe and subscribers who lose access (on CLOSE or reassignment) are simply
skipped; update canRead(Actor, CaseEntity) to treat Role.PATIENT as allowed when
actor.userId() equals entity.getPatientId() (or appropriate patient id field),
and modify the subscriber loops (listSubscribers and caseSubscribers handling in
CaseRealtimeService) to compute and use the previous readable audience or
explicitly publish a minimal revocation/update event via sendEvent before
removing/closing emitters (use closeEmitter) so clients that lose access receive
a final event to refresh/remove the stale case.

}

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

For Spring Framework SseEmitter, can SseEmitter.send(...) throw IllegalStateException or another RuntimeException when the emitter is already completed or disconnected?

💡 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 IllegalStateException when sending to stale SSE emitters.

SseEmitter.send(...) throws IllegalStateException when the emitter is already completed, not just IOException for disconnections. The current catch clause only handles IOException, allowing IllegalStateException to escape and potentially break transactional operations in the caller.

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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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;
}
private boolean sendEvent(SseEmitter emitter, String eventName, CaseRealtimeEvent payload) {
try {
emitter.send(SseEmitter.event().name(eventName).data(payload));
return true;
} catch (IOException | IllegalStateException ex) {
log.debug("Failed to send SSE event '{}': {}", eventName, ex.getMessage());
closeEmitter(emitter);
return false;
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/org/example/projektarendehantering/application/service/CaseRealtimeService.java`
around lines 155 - 163, The sendEvent method currently only catches IOException,
but SseEmitter.send(...) can also throw IllegalStateException for
completed/stale emitters; update sendEvent (and any similar send wrappers) to
catch IllegalStateException as well (either add a separate
catch(IllegalStateException ex) or broaden to catch(IOException |
IllegalStateException ex)), log the eventName and exception message similarly to
the existing log.debug line, call closeEmitter(emitter) in that branch, and
return false so stale emitters don't propagate exceptions to callers.

}

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
Expand Up @@ -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) {
Expand All @@ -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");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/org/example/projektarendehantering/application/service/CaseService.java`
at line 68, In CaseService replace direct calls to
caseRealtimeService.publishCaseEvent(...) (e.g., the calls in CaseService where
notes/case updates are published) with an after-commit scheduling helper so
events are emitted only when the transaction successfully commits; implement a
small helper (e.g., runAfterCommit or use
TransactionSynchronizationManager.registerSynchronization / publish an
ApplicationEvent consumed by `@TransactionalEventListener`(phase = AFTER_COMMIT))
and use it to invoke caseRealtimeService.publishCaseEvent(caseEntity.getId(),
"note-added", ...) (and the other occurrences) instead of calling
publishCaseEvent synchronously.

}

@Transactional
Expand Down Expand Up @@ -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);
}

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

Expand All @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Publish document-added only after the transaction commits.

Line 117 sends the SSE event while uploadDocument is still transactional. A later commit failure would still notify clients, and clients may reload before the document/status/audit changes are visible.

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
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/org/example/projektarendehantering/application/service/DocumentService.java`
at line 117, uploadDocument currently publishes the SSE event via
caseRealtimeService.publishCaseEvent(caseEntity.getId(), "document-added",
"Document uploaded") while still inside the transaction; move this publish into
a transaction after-commit hook so the event is emitted only if the transaction
successfully commits. Locate the uploadDocument method and replace the direct
publish call with registering a TransactionSynchronization (via
TransactionSynchronizationManager.registerSynchronization or equivalent) that
calls caseRealtimeService.publishCaseEvent(...) in its afterCommit()
implementation, ensuring any variables used (caseEntity.getId(), event name,
message) are captured or final/effectively final for use in the callback.

return documentMapper.toDTO(saved);
} catch (RuntimeException ex) {
if (!syncActive) {
Expand Down Expand Up @@ -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");
}
}
);
Expand All @@ -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");
}
}

Expand Down
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);
}
}
Loading