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.RoomAvailabilityWithRateResponse import com.android.trisolarisserver.controller.dto.RoomBoardResponse import com.android.trisolarisserver.controller.dto.RoomBoardStatus import com.android.trisolarisserver.controller.dto.RoomResponse import com.android.trisolarisserver.controller.dto.RoomUpsertRequest import com.android.trisolarisserver.repo.PropertyRepo import com.android.trisolarisserver.repo.PropertyUserRepo import com.android.trisolarisserver.repo.IssuedCardRepo import com.android.trisolarisserver.repo.RoomImageRepo import com.android.trisolarisserver.repo.RoomRepo import com.android.trisolarisserver.repo.RoomStayRepo import com.android.trisolarisserver.repo.RoomTypeRepo import com.android.trisolarisserver.repo.RatePlanRepo import com.android.trisolarisserver.repo.RateCalendarRepo import com.android.trisolarisserver.models.room.Room import com.android.trisolarisserver.models.room.RatePlan import com.android.trisolarisserver.models.property.Role import com.android.trisolarisserver.security.MyPrincipal import org.springframework.http.HttpStatus import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.PutMapping import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.RequestBody 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.OffsetDateTime import java.time.ZoneId import java.nio.file.Files import java.nio.file.Paths import java.util.UUID @RestController @RequestMapping("/properties/{propertyId}/rooms") class Rooms( private val propertyAccess: PropertyAccess, private val roomRepo: RoomRepo, private val roomImageRepo: RoomImageRepo, private val roomStayRepo: RoomStayRepo, private val propertyRepo: PropertyRepo, private val roomTypeRepo: RoomTypeRepo, private val issuedCardRepo: IssuedCardRepo, private val propertyUserRepo: PropertyUserRepo, private val ratePlanRepo: RatePlanRepo, private val rateCalendarRepo: RateCalendarRepo, private val roomBoardEvents: RoomBoardEvents ) { @GetMapping fun listRooms( @PathVariable propertyId: UUID, @AuthenticationPrincipal principal: MyPrincipal? ): List { requirePrincipal(principal) propertyAccess.requireMember(propertyId, principal!!.userId) val roles = propertyUserRepo.findRolesByPropertyAndUser(propertyId, principal.userId) val rooms = roomRepo.findByPropertyIdOrderByRoomNumber(propertyId) val tempCardsByRoom = loadActiveTempCardsByRoom(propertyId) if (isAgentOnly(roles)) { val occupiedRoomIds = roomStayRepo.findOccupiedRoomIds(propertyId).toHashSet() return rooms .filter { it.active && !it.maintenance && !occupiedRoomIds.contains(it.id) } .map { it.toRoomResponse(tempCardsByRoom[it.id]) } } return rooms .map { it.toRoomResponse(tempCardsByRoom[it.id]) } } @GetMapping("/board") fun roomBoard( @PathVariable propertyId: UUID, @AuthenticationPrincipal principal: MyPrincipal? ): List { requirePrincipal(principal) propertyAccess.requireMember(propertyId, principal!!.userId) val rooms = roomRepo.findByPropertyIdOrderByRoomNumber(propertyId) val occupiedRoomIds = roomStayRepo.findOccupiedRoomIds(propertyId).toHashSet() val roles = propertyUserRepo.findRolesByPropertyAndUser(propertyId, principal.userId) val mapped = 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 ) } 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, @AuthenticationPrincipal principal: MyPrincipal? ): List { requirePrincipal(principal) propertyAccess.requireMember(propertyId, principal!!.userId) val rooms = roomRepo.findByPropertyIdOrderByRoomNumber(propertyId) val occupiedRoomIds = roomStayRepo.findOccupiedRoomIds(propertyId).toHashSet() val freeRooms = rooms.filter { it.active && !it.maintenance && !occupiedRoomIds.contains(it.id) } val grouped = freeRooms.groupBy { it.roomType.name } return grouped.entries.map { (typeName, roomList) -> RoomAvailabilityResponse( roomTypeName = typeName, freeRoomNumbers = roomList.map { it.roomNumber } ) }.sortedBy { it.roomTypeName } } @GetMapping("/available") fun availableRooms( @PathVariable propertyId: UUID, @AuthenticationPrincipal principal: MyPrincipal? ): List { val rooms = roomRepo.findByPropertyIdOrderByRoomNumber(propertyId) val occupiedRoomIds = roomStayRepo.findOccupiedRoomIds(propertyId).toHashSet() val tempCardsByRoom = loadActiveTempCardsByRoom(propertyId) return rooms .filter { it.active && !it.maintenance && !occupiedRoomIds.contains(it.id) } .map { it.toRoomResponse(tempCardsByRoom[it.id]) } } @GetMapping("/by-type/{roomTypeCode}") fun roomsByType( @PathVariable propertyId: UUID, @PathVariable roomTypeCode: String, @AuthenticationPrincipal principal: MyPrincipal?, @org.springframework.web.bind.annotation.RequestParam("availableOnly", required = false, defaultValue = "false") availableOnly: Boolean ): List { val roomType = roomTypeRepo.findByPropertyIdAndCodeIgnoreCase(propertyId, roomTypeCode) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room type not found") val rooms = roomRepo.findByPropertyIdOrderByRoomNumber(propertyId) .filter { it.roomType.id == roomType.id } val tempCardsByRoom = loadActiveTempCardsByRoom(propertyId) if (availableOnly || (principal != null && isAgentOnly(propertyUserRepo.findRolesByPropertyAndUser(propertyId, principal.userId)))) { val occupiedRoomIds = roomStayRepo.findOccupiedRoomIds(propertyId).toHashSet() return rooms .filter { it.active && !it.maintenance && !occupiedRoomIds.contains(it.id) } .map { it.toRoomResponse(tempCardsByRoom[it.id]) } } return rooms.map { it.toRoomResponse(tempCardsByRoom[it.id]) } } @GetMapping("/availability-range") fun roomAvailabilityRange( @PathVariable propertyId: UUID, @AuthenticationPrincipal principal: MyPrincipal?, @org.springframework.web.bind.annotation.RequestParam("from") from: String, @org.springframework.web.bind.annotation.RequestParam("to") to: String ): List { requirePrincipal(principal) propertyAccess.requireMember(propertyId, principal!!.userId) val property = propertyRepo.findById(propertyId).orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found") } val fromDate = parseDate(from) val toDate = parseDate(to) if (!toDate.isAfter(fromDate)) { throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid date range") } val zone = ZoneId.of(property.timezone) val fromAt = fromDate.atStartOfDay(zone).toOffsetDateTime() val toAt = toDate.atStartOfDay(zone).toOffsetDateTime() val rooms = roomRepo.findByPropertyIdOrderByRoomNumber(propertyId) val occupiedRoomIds = roomStayRepo.findOccupiedRoomIdsBetween(propertyId, fromAt, toAt).toHashSet() val freeRooms = rooms.filter { it.active && !it.maintenance && !occupiedRoomIds.contains(it.id) } val grouped = freeRooms.groupBy { it.roomType.name } return grouped.entries.map { (typeName, roomList) -> RoomAvailabilityRangeResponse( roomTypeName = typeName, freeRoomNumbers = roomList.map { it.roomNumber }, freeCount = roomList.size ) }.sortedBy { it.roomTypeName } } @GetMapping("/available-range-with-rate") fun availableRoomsWithRate( @PathVariable propertyId: UUID, @AuthenticationPrincipal principal: MyPrincipal?, @org.springframework.web.bind.annotation.RequestParam("from") from: String, @org.springframework.web.bind.annotation.RequestParam("to") to: String, @org.springframework.web.bind.annotation.RequestParam("ratePlanCode", required = false) ratePlanCode: String? ): List { requirePrincipal(principal) propertyAccess.requireMember(propertyId, principal!!.userId) val property = propertyRepo.findById(propertyId).orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found") } val fromDate = parseDate(from) val toDate = parseDate(to) if (!toDate.isAfter(fromDate)) { throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid date range") } val zone = ZoneId.of(property.timezone) val fromAt = fromDate.atStartOfDay(zone).toOffsetDateTime() val toAt = toDate.atStartOfDay(zone).toOffsetDateTime() val rooms = roomRepo.findByPropertyIdOrderByRoomNumber(propertyId) val occupiedRoomIds = roomStayRepo.findOccupiedRoomIdsBetween(propertyId, fromAt, toAt).toHashSet() val freeRooms = rooms.filter { it.active && !it.maintenance && !occupiedRoomIds.contains(it.id) } if (freeRooms.isEmpty()) return emptyList() val plans = ratePlanRepo.findByPropertyIdOrderByCode(propertyId) val plansByRoomType = plans.groupBy { it.roomType.id!! } val averageCache = mutableMapOf() return freeRooms.map { room -> val roomType = room.roomType val chosenPlan = selectRatePlan(plansByRoomType[roomType.id!!], ratePlanCode) val average = when { chosenPlan != null -> averageCache.getOrPut(chosenPlan.id!!) { val avg = averageRateForPlan(chosenPlan, fromDate, toDate) RateAverage(avg, chosenPlan.currency, chosenPlan.code) } roomType.defaultRate != null -> RateAverage(roomType.defaultRate!!.toDouble(), property.currency, null) else -> RateAverage(null, property.currency, null) } RoomAvailabilityWithRateResponse( roomId = room.id!!, roomNumber = room.roomNumber, roomTypeCode = roomType.code, roomTypeName = roomType.name, averageRate = average.averageRate, currency = average.currency, ratePlanCode = average.ratePlanCode ) } } @PostMapping @ResponseStatus(HttpStatus.CREATED) fun createRoom( @PathVariable propertyId: UUID, @AuthenticationPrincipal principal: MyPrincipal?, @RequestBody request: RoomUpsertRequest ): RoomResponse { requirePrincipal(principal) propertyAccess.requireMember(propertyId, principal!!.userId) if (roomRepo.existsByPropertyIdAndRoomNumber(propertyId, request.roomNumber)) { throw ResponseStatusException(HttpStatus.CONFLICT, "Room number already exists for property") } val property = propertyRepo.findById(propertyId).orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found") } val roomType = resolveRoomType(propertyId, request) val room = Room( property = property, roomType = roomType, roomNumber = request.roomNumber, floor = request.floor, hasNfc = request.hasNfc, active = request.active, maintenance = request.maintenance, notes = request.notes ) val saved = roomRepo.save(room) val response = RoomResponse( id = saved.id ?: throw IllegalStateException("Room id is null"), roomNumber = saved.roomNumber, floor = saved.floor, roomTypeName = roomType.name, hasNfc = saved.hasNfc, active = saved.active, maintenance = saved.maintenance, notes = saved.notes, tempCardActive = false, tempCardExpiresAt = null ) roomBoardEvents.emit(propertyId) return response } @PutMapping("/{roomId}") fun updateRoom( @PathVariable propertyId: UUID, @PathVariable roomId: UUID, @AuthenticationPrincipal principal: MyPrincipal?, @RequestBody request: RoomUpsertRequest ): RoomResponse { requirePrincipal(principal) propertyAccess.requireMember(propertyId, principal!!.userId) val room = roomRepo.findByIdAndPropertyId(roomId, propertyId) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room not found for property") if (roomRepo.existsByPropertyIdAndRoomNumberAndIdNot(propertyId, request.roomNumber, roomId)) { throw ResponseStatusException(HttpStatus.CONFLICT, "Room number already exists for property") } val roomType = resolveRoomType(propertyId, request) room.roomNumber = request.roomNumber room.floor = request.floor room.roomType = roomType room.hasNfc = request.hasNfc room.active = request.active room.maintenance = request.maintenance room.notes = request.notes val saved = roomRepo.save(room) val response = RoomResponse( id = saved.id ?: throw IllegalStateException("Room id is null"), roomNumber = saved.roomNumber, floor = saved.floor, roomTypeName = roomType.name, hasNfc = saved.hasNfc, active = saved.active, maintenance = saved.maintenance, notes = saved.notes, tempCardActive = false, tempCardExpiresAt = null ) roomBoardEvents.emit(propertyId) return response } @DeleteMapping("/{roomId}") @ResponseStatus(HttpStatus.NO_CONTENT) fun deleteRoom( @PathVariable propertyId: UUID, @PathVariable roomId: UUID, @AuthenticationPrincipal principal: MyPrincipal? ) { requirePrincipal(principal) propertyAccess.requireMember(propertyId, principal!!.userId) propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN, Role.MANAGER) val room = roomRepo.findByIdAndPropertyId(roomId, propertyId) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room not found for property") if (roomStayRepo.existsByRoomId(roomId)) { throw ResponseStatusException(HttpStatus.CONFLICT, "Cannot delete room with stays") } val images = roomImageRepo.findByRoomIdOrdered(roomId) for (img in images) { try { Files.deleteIfExists(Paths.get(img.originalPath)) Files.deleteIfExists(Paths.get(img.thumbnailPath)) } catch (ex: Exception) { throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to delete room image files") } } if (images.isNotEmpty()) { roomImageRepo.deleteAll(images) } roomRepo.delete(room) roomBoardEvents.emit(propertyId) } private fun requirePrincipal(principal: MyPrincipal?) { if (principal == null) { throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal") } } private fun isAgentOnly(roles: Set): Boolean { if (!roles.contains(Role.AGENT)) return false val privileged = setOf(Role.ADMIN, Role.MANAGER, Role.STAFF, Role.HOUSEKEEPING, Role.FINANCE) return roles.none { it in privileged } } private fun parseDate(value: String): LocalDate { return try { LocalDate.parse(value.trim()) } catch (_: Exception) { throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid date format") } } private fun selectRatePlan(plans: List?, ratePlanCode: String?): RatePlan? { if (plans.isNullOrEmpty()) return null if (!ratePlanCode.isNullOrBlank()) { return plans.firstOrNull { it.code.equals(ratePlanCode, ignoreCase = true) } } return plans.firstOrNull() } private fun averageRateForPlan(plan: RatePlan, fromDate: LocalDate, toDate: LocalDate): Double { val overrides = rateCalendarRepo .findByRatePlanIdAndRateDateBetweenOrderByRateDateAsc(plan.id!!, fromDate, toDate) .associateBy { it.rateDate } var total = 0L var days = 0 var date = fromDate while (!date.isAfter(toDate)) { days += 1 total += overrides[date]?.rate ?: plan.baseRate date = date.plusDays(1) } return if (days == 0) 0.0 else total.toDouble() / days } private data class RateAverage( val averageRate: Double?, val currency: String, val ratePlanCode: String? ) private fun resolveRoomType(propertyId: UUID, request: RoomUpsertRequest): com.android.trisolarisserver.models.room.RoomType { val code = request.roomTypeCode.trim() if (code.isBlank()) { throw ResponseStatusException(HttpStatus.BAD_REQUEST, "roomTypeCode required") } return roomTypeRepo.findByPropertyIdAndCodeIgnoreCase(propertyId, code) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room type not found") } private fun loadActiveTempCardsByRoom(propertyId: UUID): Map { val now = OffsetDateTime.now() return issuedCardRepo.findActiveTempCardsForProperty(propertyId, now) .groupBy { it.room.id ?: throw IllegalStateException("Room id is null") } .mapValues { (_, cards) -> cards.maxBy { it.expiresAt }.expiresAt } } } private fun Room.toRoomResponse(tempCardExpiresAt: OffsetDateTime? = null): RoomResponse { val roomId = id ?: throw IllegalStateException("Room id is null") return RoomResponse( id = roomId, roomNumber = roomNumber, floor = floor, roomTypeName = roomType.name, hasNfc = hasNfc, active = active, maintenance = maintenance, notes = notes, tempCardActive = tempCardExpiresAt != null, tempCardExpiresAt = tempCardExpiresAt?.toString() ) }