Files
TrisolarisServer/src/main/kotlin/com/android/trisolarisserver/controller/Rooms.kt
androidlover5842 b52cb1a88d
All checks were successful
build-and-deploy / build-deploy (push) Successful in 33s
Expose active temp card state on room responses
2026-01-28 17:56:59 +05:30

385 lines
16 KiB
Kotlin

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<RoomResponse> {
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<RoomBoardResponse> {
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<RoomAvailabilityResponse> {
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<RoomResponse> {
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<RoomResponse> {
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<RoomAvailabilityRangeResponse> {
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<Role>): 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<UUID, OffsetDateTime> {
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()
)
}