diff --git a/src/main/kotlin/com/android/trisolarisserver/component/BookingEvents.kt b/src/main/kotlin/com/android/trisolarisserver/component/BookingEvents.kt new file mode 100644 index 0000000..083cf5d --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/component/BookingEvents.kt @@ -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("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 +) diff --git a/src/main/kotlin/com/android/trisolarisserver/component/DocumentExtractionService.kt b/src/main/kotlin/com/android/trisolarisserver/component/DocumentExtractionService.kt index 29c7983..965f2d2 100644 --- a/src/main/kotlin/com/android/trisolarisserver/component/DocumentExtractionService.kt +++ b/src/main/kotlin/com/android/trisolarisserver/component/DocumentExtractionService.kt @@ -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) } } } diff --git a/src/main/kotlin/com/android/trisolarisserver/controller/BookingFlow.kt b/src/main/kotlin/com/android/trisolarisserver/controller/BookingFlow.kt index a34efa0..0de2835 100644 --- a/src/main/kotlin/com/android/trisolarisserver/controller/BookingFlow.kt +++ b/src/main/kotlin/com/android/trisolarisserver/controller/BookingFlow.kt @@ -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 { diff --git a/src/main/kotlin/com/android/trisolarisserver/controller/BookingSnapshotBuilder.kt b/src/main/kotlin/com/android/trisolarisserver/controller/BookingSnapshotBuilder.kt new file mode 100644 index 0000000..647d815 --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/controller/BookingSnapshotBuilder.kt @@ -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 + ) + } +} diff --git a/src/main/kotlin/com/android/trisolarisserver/controller/Charges.kt b/src/main/kotlin/com/android/trisolarisserver/controller/Charges.kt index 547e972..5f47955 100644 --- a/src/main/kotlin/com/android/trisolarisserver/controller/Charges.kt +++ b/src/main/kotlin/com/android/trisolarisserver/controller/Charges.kt @@ -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 diff --git a/src/main/kotlin/com/android/trisolarisserver/controller/Payments.kt b/src/main/kotlin/com/android/trisolarisserver/controller/Payments.kt index 5f12d81..658f499 100644 --- a/src/main/kotlin/com/android/trisolarisserver/controller/Payments.kt +++ b/src/main/kotlin/com/android/trisolarisserver/controller/Payments.kt @@ -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 {