Add booking SSE stream and emit on updates
All checks were successful
build-and-deploy / build-deploy (push) Successful in 32s

This commit is contained in:
androidlover5842
2026-01-31 13:30:51 +05:30
parent db6ea5d529
commit 69df1429fa
6 changed files with 163 additions and 55 deletions

View File

@@ -0,0 +1,35 @@
package com.android.trisolarisserver.component
import com.android.trisolarisserver.controller.BookingSnapshotBuilder
import com.android.trisolarisserver.controller.dto.BookingDetailResponse
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Component
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter
import java.util.UUID
@Component
class BookingEvents(
private val bookingSnapshotBuilder: BookingSnapshotBuilder
) {
private val hub = SseHub<BookingKey>("booking") { key ->
bookingSnapshotBuilder.build(key.propertyId, key.bookingId)
}
fun subscribe(propertyId: UUID, bookingId: UUID): SseEmitter {
return hub.subscribe(BookingKey(propertyId, bookingId))
}
fun emit(propertyId: UUID, bookingId: UUID) {
hub.emit(BookingKey(propertyId, bookingId))
}
@Scheduled(fixedDelayString = "25000")
fun heartbeat() {
hub.heartbeat()
}
}
private data class BookingKey(
val propertyId: UUID,
val bookingId: UUID
)

View File

@@ -19,7 +19,8 @@ class DocumentExtractionService(
private val propertyRepo: PropertyRepo,
private val paddleOcrClient: PaddleOcrClient,
private val bookingRepo: BookingRepo,
private val pincodeResolver: PincodeResolver
private val pincodeResolver: PincodeResolver,
private val bookingEvents: BookingEvents
) {
private val logger = LoggerFactory.getLogger(DocumentExtractionService::class.java)
@@ -476,6 +477,9 @@ class DocumentExtractionService(
if (updated) {
booking.updatedAt = OffsetDateTime.now()
bookingRepo.save(booking)
val propertyId = booking.property.id ?: return
val bookingId = booking.id ?: return
bookingEvents.emit(propertyId, bookingId)
}
}
}

View File

@@ -1,5 +1,6 @@
package com.android.trisolarisserver.controller
import com.android.trisolarisserver.component.BookingEvents
import com.android.trisolarisserver.component.PropertyAccess
import com.android.trisolarisserver.component.RoomBoardEvents
import com.android.trisolarisserver.controller.dto.BookingCancelRequest
@@ -57,10 +58,12 @@ class BookingFlow(
private val appUserRepo: AppUserRepo,
private val propertyRepo: PropertyRepo,
private val roomBoardEvents: RoomBoardEvents,
private val bookingEvents: BookingEvents,
private val guestVehicleRepo: GuestVehicleRepo,
private val guestRatingRepo: GuestRatingRepo,
private val guestDocumentRepo: GuestDocumentRepo,
private val paymentRepo: PaymentRepo
private val paymentRepo: PaymentRepo,
private val bookingSnapshotBuilder: BookingSnapshotBuilder
) {
@PostMapping
@@ -131,6 +134,7 @@ class BookingFlow(
)
val saved = bookingRepo.save(booking)
bookingEvents.emit(propertyId, saved.id!!)
return BookingCreateResponse(
id = saved.id!!,
status = saved.status.name,
@@ -237,57 +241,27 @@ class BookingFlow(
Role.FINANCE
)
val booking = requireBooking(propertyId, bookingId)
val stays = roomStayRepo.findByBookingId(bookingId)
val activeRooms = stays.filter { it.toAt == null }
val roomsToShow = if (activeRooms.isNotEmpty()) activeRooms else stays
val roomNumbers = roomsToShow.map { it.room.roomNumber }
.distinct()
.sorted()
return bookingSnapshotBuilder.build(propertyId, bookingId)
}
val guest = booking.primaryGuest
val signatureUrl = guest?.signaturePath?.let {
"/properties/$propertyId/guests/${guest.id}/signature/file"
}
val totalNightlyRate = roomsToShow.sumOf { it.nightlyRate ?: 0L }
val expectedPay = computeExpectedPayTotal(stays, booking.expectedCheckoutAt, booking.property.timezone)
val accruedPay = computeExpectedPay(stays, booking.property.timezone)
val amountCollected = paymentRepo.sumAmountByBookingId(bookingId)
val pending = accruedPay - amountCollected
return BookingDetailResponse(
id = booking.id!!,
status = booking.status.name,
guestId = guest?.id,
guestName = guest?.name,
guestPhone = guest?.phoneE164,
guestNationality = guest?.nationality,
guestAddressText = guest?.addressText,
guestSignatureUrl = signatureUrl,
roomNumbers = roomNumbers,
source = booking.source,
fromCity = booking.fromCity,
toCity = booking.toCity,
memberRelation = booking.memberRelation?.name,
transportMode = booking.transportMode?.name,
checkInAt = booking.checkinAt?.toString(),
checkOutAt = booking.checkoutAt?.toString(),
expectedCheckInAt = booking.expectedCheckinAt?.toString(),
expectedCheckOutAt = booking.expectedCheckoutAt?.toString(),
adultCount = booking.adultCount,
childCount = booking.childCount,
maleCount = booking.maleCount,
femaleCount = booking.femaleCount,
totalGuestCount = booking.totalGuestCount,
expectedGuestCount = booking.expectedGuestCount,
notes = booking.notes,
registeredByName = booking.createdBy?.name,
registeredByPhone = booking.createdBy?.phoneE164,
totalNightlyRate = totalNightlyRate,
expectedPay = expectedPay,
amountCollected = amountCollected,
pending = pending
@GetMapping("/{bookingId}/stream")
fun streamBooking(
@PathVariable propertyId: UUID,
@PathVariable bookingId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
): org.springframework.web.servlet.mvc.method.annotation.SseEmitter {
requireRole(
propertyAccess,
propertyId,
principal,
Role.ADMIN,
Role.MANAGER,
Role.STAFF,
Role.HOUSEKEEPING,
Role.FINANCE
)
requireBooking(propertyId, bookingId)
return bookingEvents.subscribe(propertyId, bookingId)
}
@PostMapping("/{bookingId}/link-guest")
@@ -311,6 +285,7 @@ class BookingFlow(
booking.primaryGuest = guest
booking.updatedAt = OffsetDateTime.now()
bookingRepo.save(booking)
bookingEvents.emit(propertyId, bookingId)
if (previous != null && previous.id != guest.id && isPlaceholderGuest(previous) && isSafeToDelete(previous)) {
guestRepo.delete(previous)
}
@@ -382,6 +357,7 @@ class BookingFlow(
booking.updatedAt = now
bookingRepo.save(booking)
roomBoardEvents.emit(propertyId)
bookingEvents.emit(propertyId, bookingId)
}
@PostMapping("/{bookingId}/check-in/bulk")
@@ -469,6 +445,7 @@ class BookingFlow(
booking.updatedAt = now
bookingRepo.save(booking)
roomBoardEvents.emit(propertyId)
bookingEvents.emit(propertyId, bookingId)
}
@PostMapping("/{bookingId}/expected-dates")
@@ -514,6 +491,7 @@ class BookingFlow(
booking.updatedAt = OffsetDateTime.now()
bookingRepo.save(booking)
bookingEvents.emit(propertyId, bookingId)
}
@PostMapping("/{bookingId}/check-out")
@@ -543,6 +521,7 @@ class BookingFlow(
booking.updatedAt = now
bookingRepo.save(booking)
roomBoardEvents.emit(propertyId)
bookingEvents.emit(propertyId, bookingId)
}
private fun resolveGuestForBooking(
@@ -603,6 +582,7 @@ class BookingFlow(
if (request.reason != null) booking.notes = request.reason
booking.updatedAt = OffsetDateTime.now()
bookingRepo.save(booking)
bookingEvents.emit(propertyId, bookingId)
}
@PostMapping("/{bookingId}/no-show")
@@ -623,6 +603,7 @@ class BookingFlow(
if (request.reason != null) booking.notes = request.reason
booking.updatedAt = OffsetDateTime.now()
bookingRepo.save(booking)
bookingEvents.emit(propertyId, bookingId)
}
@PostMapping("/{bookingId}/room-stays")
@@ -671,6 +652,7 @@ class BookingFlow(
createdBy = actor
)
roomStayRepo.save(stay)
bookingEvents.emit(propertyId, bookingId)
}
private fun requireBooking(propertyId: UUID, bookingId: UUID): com.android.trisolarisserver.models.booking.Booking {

View File

@@ -0,0 +1,78 @@
package com.android.trisolarisserver.controller
import com.android.trisolarisserver.controller.dto.BookingDetailResponse
import com.android.trisolarisserver.db.repo.BookingRepo
import com.android.trisolarisserver.repo.PaymentRepo
import com.android.trisolarisserver.repo.RoomStayRepo
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Component
import org.springframework.web.server.ResponseStatusException
import java.util.UUID
@Component
class BookingSnapshotBuilder(
private val bookingRepo: BookingRepo,
private val roomStayRepo: RoomStayRepo,
private val paymentRepo: PaymentRepo
) {
fun build(propertyId: UUID, bookingId: UUID): BookingDetailResponse {
val booking = bookingRepo.findById(bookingId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found")
}
if (booking.property.id != propertyId) {
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found for property")
}
val stays = roomStayRepo.findByBookingId(bookingId)
val activeRooms = stays.filter { it.toAt == null }
val roomsToShow = activeRooms.ifEmpty { stays }
val roomNumbers = roomsToShow.map { it.room.roomNumber }
.distinct()
.sorted()
val guest = booking.primaryGuest
val signatureUrl = guest?.signaturePath?.let {
"/properties/$propertyId/guests/${guest.id}/signature/file"
}
val totalNightlyRate = roomsToShow.sumOf { it.nightlyRate ?: 0L }
val expectedPay = computeExpectedPayTotal(stays, booking.expectedCheckoutAt, booking.property.timezone)
val accruedPay = computeExpectedPay(stays, booking.property.timezone)
val amountCollected = paymentRepo.sumAmountByBookingId(bookingId)
val pending = accruedPay - amountCollected
return BookingDetailResponse(
id = booking.id!!,
status = booking.status.name,
guestId = guest?.id,
guestName = guest?.name,
guestPhone = guest?.phoneE164,
guestNationality = guest?.nationality,
guestAddressText = guest?.addressText,
guestSignatureUrl = signatureUrl,
roomNumbers = roomNumbers,
source = booking.source,
fromCity = booking.fromCity,
toCity = booking.toCity,
memberRelation = booking.memberRelation?.name,
transportMode = booking.transportMode?.name,
checkInAt = booking.checkinAt?.toString(),
checkOutAt = booking.checkoutAt?.toString(),
expectedCheckInAt = booking.expectedCheckinAt?.toString(),
expectedCheckOutAt = booking.expectedCheckoutAt?.toString(),
adultCount = booking.adultCount,
childCount = booking.childCount,
maleCount = booking.maleCount,
femaleCount = booking.femaleCount,
totalGuestCount = booking.totalGuestCount,
expectedGuestCount = booking.expectedGuestCount,
notes = booking.notes,
registeredByName = booking.createdBy?.name,
registeredByPhone = booking.createdBy?.phoneE164,
totalNightlyRate = totalNightlyRate,
expectedPay = expectedPay,
amountCollected = amountCollected,
pending = pending
)
}
}

View File

@@ -1,5 +1,6 @@
package com.android.trisolarisserver.controller
import com.android.trisolarisserver.component.BookingEvents
import com.android.trisolarisserver.component.PropertyAccess
import com.android.trisolarisserver.controller.dto.ChargeCreateRequest
import com.android.trisolarisserver.controller.dto.ChargeResponse
@@ -29,7 +30,8 @@ class Charges(
private val propertyAccess: PropertyAccess,
private val bookingRepo: BookingRepo,
private val chargeRepo: ChargeRepo,
private val appUserRepo: AppUserRepo
private val appUserRepo: AppUserRepo,
private val bookingEvents: BookingEvents
) {
@PostMapping
@@ -65,7 +67,9 @@ class Charges(
occurredAt = occurredAt,
createdBy = createdBy
)
return chargeRepo.save(charge).toResponse()
val saved = chargeRepo.save(charge).toResponse()
bookingEvents.emit(propertyId, bookingId)
return saved
}
@GetMapping

View File

@@ -1,5 +1,6 @@
package com.android.trisolarisserver.controller
import com.android.trisolarisserver.component.BookingEvents
import com.android.trisolarisserver.component.PropertyAccess
import com.android.trisolarisserver.controller.dto.PaymentCreateRequest
import com.android.trisolarisserver.controller.dto.PaymentResponse
@@ -34,7 +35,8 @@ class Payments(
private val propertyRepo: PropertyRepo,
private val bookingRepo: BookingRepo,
private val paymentRepo: PaymentRepo,
private val appUserRepo: AppUserRepo
private val appUserRepo: AppUserRepo,
private val bookingEvents: BookingEvents
) {
@PostMapping
@@ -72,7 +74,9 @@ class Payments(
receivedAt = receivedAt,
receivedBy = appUserRepo.findById(actor.userId).orElse(null)
)
return paymentRepo.save(payment).toResponse()
val saved = paymentRepo.save(payment).toResponse()
bookingEvents.emit(propertyId, bookingId)
return saved
}
@GetMapping
@@ -122,6 +126,7 @@ class Payments(
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Only CASH payments can be deleted")
}
paymentRepo.delete(payment)
bookingEvents.emit(propertyId, bookingId)
}
private fun parseMethod(value: String): PaymentMethod {