diff --git a/src/main/kotlin/com/android/trisolarisserver/component/RoomBoardEvents.kt b/src/main/kotlin/com/android/trisolarisserver/component/RoomBoardEvents.kt new file mode 100644 index 0000000..115790f --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/component/RoomBoardEvents.kt @@ -0,0 +1,86 @@ +package com.android.trisolarisserver.component + +import com.android.trisolarisserver.controller.dto.RoomBoardResponse +import com.android.trisolarisserver.controller.dto.RoomBoardStatus +import com.android.trisolarisserver.repo.RoomRepo +import com.android.trisolarisserver.repo.RoomStayRepo +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Component +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter +import java.io.IOException +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CopyOnWriteArrayList + +@Component +class RoomBoardEvents( + private val roomRepo: RoomRepo, + private val roomStayRepo: RoomStayRepo +) { + private val emitters: MutableMap> = ConcurrentHashMap() + + fun subscribe(propertyId: UUID): SseEmitter { + val emitter = SseEmitter(0L) + emitters.computeIfAbsent(propertyId) { CopyOnWriteArrayList() }.add(emitter) + emitter.onCompletion { emitters[propertyId]?.remove(emitter) } + emitter.onTimeout { emitters[propertyId]?.remove(emitter) } + emitter.onError { emitters[propertyId]?.remove(emitter) } + try { + emitter.send(SseEmitter.event().name("room-board").data(buildSnapshot(propertyId))) + } catch (_: IOException) { + emitters[propertyId]?.remove(emitter) + } + return emitter + } + + fun emit(propertyId: UUID) { + val data = buildSnapshot(propertyId) + val list = emitters[propertyId] ?: return + val dead = mutableListOf() + for (emitter in list) { + try { + emitter.send(SseEmitter.event().name("room-board").data(data)) + } catch (_: IOException) { + dead.add(emitter) + } + } + if (dead.isNotEmpty()) { + list.removeAll(dead.toSet()) + } + } + + @Scheduled(fixedDelayString = "25000") + fun heartbeat() { + emitters.forEach { (_, list) -> + val dead = mutableListOf() + for (emitter in list) { + try { + emitter.send(SseEmitter.event().name("ping").data("ok")) + } catch (_: IOException) { + dead.add(emitter) + } + } + if (dead.isNotEmpty()) { + list.removeAll(dead.toSet()) + } + } + } + + private fun buildSnapshot(propertyId: UUID): List { + val rooms = roomRepo.findByPropertyIdOrderByRoomNumber(propertyId) + val occupiedRoomIds = roomStayRepo.findOccupiedRoomIds(propertyId).toHashSet() + return rooms.map { room -> + val status = when { + room.maintenance -> RoomBoardStatus.MAINTENANCE + !room.active -> RoomBoardStatus.INACTIVE + occupiedRoomIds.contains(room.id) -> RoomBoardStatus.OCCUPIED + else -> RoomBoardStatus.FREE + } + RoomBoardResponse( + roomNumber = room.roomNumber, + roomTypeName = room.roomType.name, + status = status + ) + } + } +} diff --git a/src/main/kotlin/com/android/trisolarisserver/controller/BookingFlow.kt b/src/main/kotlin/com/android/trisolarisserver/controller/BookingFlow.kt index 7ab790f..7a54173 100644 --- a/src/main/kotlin/com/android/trisolarisserver/controller/BookingFlow.kt +++ b/src/main/kotlin/com/android/trisolarisserver/controller/BookingFlow.kt @@ -1,6 +1,7 @@ package com.android.trisolarisserver.controller import com.android.trisolarisserver.component.PropertyAccess +import com.android.trisolarisserver.component.RoomBoardEvents import com.android.trisolarisserver.controller.dto.BookingCancelRequest import com.android.trisolarisserver.controller.dto.BookingCheckInRequest import com.android.trisolarisserver.controller.dto.BookingCheckOutRequest @@ -35,7 +36,8 @@ class BookingFlow( private val bookingRepo: BookingRepo, private val roomRepo: RoomRepo, private val roomStayRepo: RoomStayRepo, - private val appUserRepo: AppUserRepo + private val appUserRepo: AppUserRepo, + private val roomBoardEvents: RoomBoardEvents ) { @PostMapping("/{bookingId}/check-in") @@ -100,6 +102,7 @@ class BookingFlow( if (request.notes != null) booking.notes = request.notes booking.updatedAt = now bookingRepo.save(booking) + roomBoardEvents.emit(propertyId) } @PostMapping("/{bookingId}/check-out") @@ -128,6 +131,7 @@ class BookingFlow( if (request.notes != null) booking.notes = request.notes booking.updatedAt = now bookingRepo.save(booking) + roomBoardEvents.emit(propertyId) } @PostMapping("/{bookingId}/cancel") diff --git a/src/main/kotlin/com/android/trisolarisserver/controller/RoomStayFlow.kt b/src/main/kotlin/com/android/trisolarisserver/controller/RoomStayFlow.kt index ee9b056..d4831b1 100644 --- a/src/main/kotlin/com/android/trisolarisserver/controller/RoomStayFlow.kt +++ b/src/main/kotlin/com/android/trisolarisserver/controller/RoomStayFlow.kt @@ -1,6 +1,7 @@ package com.android.trisolarisserver.controller import com.android.trisolarisserver.component.PropertyAccess +import com.android.trisolarisserver.component.RoomBoardEvents import com.android.trisolarisserver.controller.dto.RoomChangeRequest import com.android.trisolarisserver.controller.dto.RoomChangeResponse import com.android.trisolarisserver.models.room.RoomStay @@ -31,7 +32,8 @@ class RoomStayFlow( private val roomStayRepo: RoomStayRepo, private val roomStayChangeRepo: RoomStayChangeRepo, private val roomRepo: RoomRepo, - private val appUserRepo: AppUserRepo + private val appUserRepo: AppUserRepo, + private val roomBoardEvents: RoomBoardEvents ) { @PostMapping("/{roomStayId}/change-room") @@ -98,6 +100,7 @@ class RoomStayFlow( idempotencyKey = request.idempotencyKey ) val savedChange = roomStayChangeRepo.save(change) + roomBoardEvents.emit(propertyId) return toResponse(savedChange) } diff --git a/src/main/kotlin/com/android/trisolarisserver/controller/Rooms.kt b/src/main/kotlin/com/android/trisolarisserver/controller/Rooms.kt index a3c9ff7..da0d42c 100644 --- a/src/main/kotlin/com/android/trisolarisserver/controller/Rooms.kt +++ b/src/main/kotlin/com/android/trisolarisserver/controller/Rooms.kt @@ -1,6 +1,7 @@ package com.android.trisolarisserver.controller import com.android.trisolarisserver.component.PropertyAccess +import com.android.trisolarisserver.component.RoomBoardEvents import com.android.trisolarisserver.controller.dto.RoomAvailabilityRangeResponse import com.android.trisolarisserver.controller.dto.RoomAvailabilityResponse import com.android.trisolarisserver.controller.dto.RoomBoardResponse @@ -26,6 +27,7 @@ import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.bind.annotation.RestController import org.springframework.web.server.ResponseStatusException +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter import java.time.LocalDate import java.time.ZoneId import java.util.UUID @@ -38,7 +40,8 @@ class Rooms( private val roomStayRepo: RoomStayRepo, private val propertyRepo: PropertyRepo, private val roomTypeRepo: RoomTypeRepo, - private val propertyUserRepo: PropertyUserRepo + private val propertyUserRepo: PropertyUserRepo, + private val roomBoardEvents: RoomBoardEvents ) { @GetMapping @@ -88,6 +91,16 @@ class Rooms( return if (isAgentOnly(roles)) mapped.filter { it.status == RoomBoardStatus.FREE } else mapped } + @GetMapping("/board/stream") + fun roomBoardStream( + @PathVariable propertyId: UUID, + @AuthenticationPrincipal principal: MyPrincipal? + ): SseEmitter { + requirePrincipal(principal) + propertyAccess.requireMember(propertyId, principal!!.userId) + return roomBoardEvents.subscribe(propertyId) + } + @GetMapping("/availability") fun roomAvailability( @PathVariable propertyId: UUID, @@ -177,7 +190,9 @@ class Rooms( notes = request.notes ) - return roomRepo.save(room).toRoomResponse() + val saved = roomRepo.save(room).toRoomResponse() + roomBoardEvents.emit(propertyId) + return saved } @PutMapping("/{roomId}") @@ -208,7 +223,9 @@ class Rooms( room.maintenance = request.maintenance room.notes = request.notes - return roomRepo.save(room).toRoomResponse() + val saved = roomRepo.save(room).toRoomResponse() + roomBoardEvents.emit(propertyId) + return saved } private fun requirePrincipal(principal: MyPrincipal?) {