diff --git a/README.md b/README.md index 79c5df5..db78452 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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. @@ -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. diff --git a/src/main/java/org/example/projektarendehantering/ProjektArendehanteringApplication.java b/src/main/java/org/example/projektarendehantering/ProjektArendehanteringApplication.java index d9a4a6c..ba719fb 100644 --- a/src/main/java/org/example/projektarendehantering/ProjektArendehanteringApplication.java +++ b/src/main/java/org/example/projektarendehantering/ProjektArendehanteringApplication.java @@ -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) { diff --git a/src/main/java/org/example/projektarendehantering/application/service/CaseRealtimeEvent.java b/src/main/java/org/example/projektarendehantering/application/service/CaseRealtimeEvent.java new file mode 100644 index 0000000..5c18a6b --- /dev/null +++ b/src/main/java/org/example/projektarendehantering/application/service/CaseRealtimeEvent.java @@ -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 +) { +} diff --git a/src/main/java/org/example/projektarendehantering/application/service/CaseRealtimeService.java b/src/main/java/org/example/projektarendehantering/application/service/CaseRealtimeService.java new file mode 100644 index 0000000..84da158 --- /dev/null +++ b/src/main/java/org/example/projektarendehantering/application/service/CaseRealtimeService.java @@ -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 listSubscribers = new ConcurrentHashMap<>(); + private final Map 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; + } + } + + 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) { + } +} diff --git a/src/main/java/org/example/projektarendehantering/application/service/CaseService.java b/src/main/java/org/example/projektarendehantering/application/service/CaseService.java index 16e7f35..7bc661e 100644 --- a/src/main/java/org/example/projektarendehantering/application/service/CaseService.java +++ b/src/main/java/org/example/projektarendehantering/application/service/CaseService.java @@ -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"); } @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 allowedRoles, String fieldName) { diff --git a/src/main/java/org/example/projektarendehantering/application/service/DocumentService.java b/src/main/java/org/example/projektarendehantering/application/service/DocumentService.java index 55a1ded..62b7cbe 100644 --- a/src/main/java/org/example/projektarendehantering/application/service/DocumentService.java +++ b/src/main/java/org/example/projektarendehantering/application/service/DocumentService.java @@ -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"); 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"); } } diff --git a/src/main/java/org/example/projektarendehantering/presentation/web/CaseRealtimeController.java b/src/main/java/org/example/projektarendehantering/presentation/web/CaseRealtimeController.java new file mode 100644 index 0000000..cf38caf --- /dev/null +++ b/src/main/java/org/example/projektarendehantering/presentation/web/CaseRealtimeController.java @@ -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); + } +} diff --git a/src/main/resources/templates/cases/detail.html b/src/main/resources/templates/cases/detail.html index 54a577e..8ba8a08 100644 --- a/src/main/resources/templates/cases/detail.html +++ b/src/main/resources/templates/cases/detail.html @@ -126,6 +126,58 @@

Error

+ diff --git a/src/main/resources/templates/cases/list.html b/src/main/resources/templates/cases/list.html index 05e71fc..92656f4 100644 --- a/src/main/resources/templates/cases/list.html +++ b/src/main/resources/templates/cases/list.html @@ -50,6 +50,55 @@

Cases

+ diff --git a/src/test/java/org/example/projektarendehantering/application/service/CaseServiceTest.java b/src/test/java/org/example/projektarendehantering/application/service/CaseServiceTest.java index 08c4e9c..9849a4a 100644 --- a/src/test/java/org/example/projektarendehantering/application/service/CaseServiceTest.java +++ b/src/test/java/org/example/projektarendehantering/application/service/CaseServiceTest.java @@ -44,6 +44,8 @@ class CaseServiceTest { private CaseNoteMapper caseNoteMapper; @Mock private AuditService auditService; + @Mock + private CaseRealtimeService caseRealtimeService; @InjectMocks private CaseService caseService; diff --git a/src/test/java/org/example/projektarendehantering/application/service/DocumentServiceTest.java b/src/test/java/org/example/projektarendehantering/application/service/DocumentServiceTest.java index b59b564..60edf8f 100644 --- a/src/test/java/org/example/projektarendehantering/application/service/DocumentServiceTest.java +++ b/src/test/java/org/example/projektarendehantering/application/service/DocumentServiceTest.java @@ -44,6 +44,8 @@ class DocumentServiceTest { private DocumentMapper documentMapper; @Mock private AuditService auditService; + @Mock + private CaseRealtimeService caseRealtimeService; @InjectMocks private DocumentService documentService; diff --git a/start-local.ps1 b/start-local.ps1 index 12d8698..9861fcc 100644 --- a/start-local.ps1 +++ b/start-local.ps1 @@ -37,7 +37,7 @@ function Invoke-CommandWithMode { if ($LASTEXITCODE -ne 0) { Write-Host "Command failed in quiet mode (exit code: $LASTEXITCODE)." -ForegroundColor Red - Write-Host "Recent output from $tempLog:" -ForegroundColor DarkRed + Write-Host "Recent output from ${tempLog}:" -ForegroundColor DarkRed Get-Content -Path $tempLog -Tail 25 | ForEach-Object { Write-Host $_ -ForegroundColor DarkGray } } } @@ -70,6 +70,33 @@ function Pause-ForEnjoyment { } } +function Open-BrowserUrl { + param([string]$Url) + + try { + if ($IsWindows -or $env:OS -eq "Windows_NT") { + Start-Process $Url + return + } + + if ($IsMacOS) { + & open $Url + return + } + + if ($IsLinux) { + & xdg-open $Url + return + } + + # Fallback for uncommon PowerShell hosts. + Start-Process $Url + } + catch { + Write-Warning "Could not automatically open browser. Open this URL manually: $Url" + } +} + if ($Serious) { Write-Host "Starting local development environment (serious mode)..." -ForegroundColor Cyan } @@ -165,41 +192,11 @@ if (-not $Serious) { Write-Host "Use --serious to see full startup logs." -ForegroundColor DarkGreen } Pause-ForEnjoyment - -$browserOpenTimeoutSeconds = 90 -$browserPollIntervalSeconds = 2 -$browserReadyJob = Start-Job -ScriptBlock { - param( - [int]$TimeoutSeconds, - [int]$PollIntervalSeconds - ) - - $uri = "http://localhost:8080" - $deadline = (Get-Date).AddSeconds($TimeoutSeconds) - - while ((Get-Date) -lt $deadline) { - try { - $response = Invoke-WebRequest -Uri $uri -Method GET -TimeoutSec 3 -ErrorAction Stop - if ($response.StatusCode -ge 200 -and $response.StatusCode -lt 400) { - Start-Process $uri - return - } - } - catch { - # App not ready yet; continue polling until timeout. - } - - Start-Sleep -Seconds $PollIntervalSeconds - } -} -ArgumentList $browserOpenTimeoutSeconds, $browserPollIntervalSeconds +Open-BrowserUrl -Url "http://localhost:8080" $mavenExitCode = Invoke-CommandWithMode -Command { .\mvnw spring-boot:run "-Dspring-boot.run.profiles=local" } if ($mavenExitCode -ne 0) { - if ($browserReadyJob) { - Stop-Job -Job $browserReadyJob -ErrorAction SilentlyContinue - Remove-Job -Job $browserReadyJob -Force -ErrorAction SilentlyContinue - } if ($mavenExitCode -in @(130, 1)) { Write-Warning "Spring Boot terminated by user (Ctrl+C)" }