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 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.models.room.Room 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 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 } } @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 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() ) }