diff --git a/src/main/kotlin/com/android/trisolarisserver/controller/BookingFlow.kt b/src/main/kotlin/com/android/trisolarisserver/controller/BookingFlow.kt new file mode 100644 index 0000000..7ba2804 --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/controller/BookingFlow.kt @@ -0,0 +1,228 @@ +package com.android.trisolarisserver.controller + +import com.android.trisolarisserver.component.PropertyAccess +import com.android.trisolarisserver.controller.dto.BookingCancelRequest +import com.android.trisolarisserver.controller.dto.BookingCheckInRequest +import com.android.trisolarisserver.controller.dto.BookingCheckOutRequest +import com.android.trisolarisserver.controller.dto.BookingNoShowRequest +import com.android.trisolarisserver.db.repo.BookingRepo +import com.android.trisolarisserver.models.booking.BookingStatus +import com.android.trisolarisserver.models.booking.TransportMode +import com.android.trisolarisserver.models.room.RoomStay +import com.android.trisolarisserver.models.property.Role +import com.android.trisolarisserver.repo.AppUserRepo +import com.android.trisolarisserver.repo.RoomRepo +import com.android.trisolarisserver.repo.RoomStayRepo +import com.android.trisolarisserver.security.MyPrincipal +import org.springframework.http.HttpStatus +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +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 java.time.OffsetDateTime +import java.util.UUID + +@RestController +@RequestMapping("/properties/{propertyId}/bookings") +class BookingFlow( + private val propertyAccess: PropertyAccess, + private val bookingRepo: BookingRepo, + private val roomRepo: RoomRepo, + private val roomStayRepo: RoomStayRepo, + private val appUserRepo: AppUserRepo +) { + + @PostMapping("/{bookingId}/check-in") + @ResponseStatus(HttpStatus.CREATED) + @Transactional + fun checkIn( + @PathVariable propertyId: UUID, + @PathVariable bookingId: UUID, + @AuthenticationPrincipal principal: MyPrincipal?, + @RequestBody request: BookingCheckInRequest + ) { + val actor = requireActor(propertyId, principal) + + val roomIds = request.roomIds.distinct() + if (roomIds.isEmpty()) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "roomIds required") + } + val booking = requireBooking(propertyId, bookingId) + if (booking.status != BookingStatus.OPEN) { + throw ResponseStatusException(HttpStatus.CONFLICT, "Booking not open") + } + + val now = OffsetDateTime.now() + val checkInAt = parseOffset(request.checkInAt) ?: now + + val rooms = roomIds.map { roomId -> + val room = roomRepo.findByIdAndPropertyId(roomId, propertyId) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room not found") + if (!room.active || room.maintenance) { + throw ResponseStatusException(HttpStatus.CONFLICT, "Room not available") + } + room + } + + val occupied = roomStayRepo.findActiveRoomIds(propertyId, rooms.mapNotNull { it.id }) + if (occupied.isNotEmpty()) { + throw ResponseStatusException(HttpStatus.CONFLICT, "Room already occupied") + } + + rooms.forEach { room -> + val stay = RoomStay( + property = booking.property, + booking = booking, + room = room, + fromAt = checkInAt, + toAt = null, + createdBy = actor + ) + roomStayRepo.save(stay) + } + + booking.status = BookingStatus.CHECKED_IN + booking.checkinAt = checkInAt + booking.transportMode = request.transportMode?.let { + val mode = parseTransportMode(it) + if (!isTransportModeAllowed(booking.property, mode)) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Transport mode disabled") + } + mode + } + booking.transportVehicleNumber = request.transportVehicleNumber + if (request.notes != null) booking.notes = request.notes + booking.updatedAt = now + bookingRepo.save(booking) + } + + @PostMapping("/{bookingId}/check-out") + @ResponseStatus(HttpStatus.NO_CONTENT) + @Transactional + fun checkOut( + @PathVariable propertyId: UUID, + @PathVariable bookingId: UUID, + @AuthenticationPrincipal principal: MyPrincipal?, + @RequestBody request: BookingCheckOutRequest + ) { + requireActor(propertyId, principal) + val booking = requireBooking(propertyId, bookingId) + if (booking.status != BookingStatus.CHECKED_IN) { + throw ResponseStatusException(HttpStatus.CONFLICT, "Booking not checked in") + } + val now = OffsetDateTime.now() + val checkOutAt = parseOffset(request.checkOutAt) ?: now + + val stays = roomStayRepo.findActiveByBookingId(bookingId) + stays.forEach { it.toAt = checkOutAt } + roomStayRepo.saveAll(stays) + + booking.status = BookingStatus.CHECKED_OUT + booking.checkoutAt = checkOutAt + if (request.notes != null) booking.notes = request.notes + booking.updatedAt = now + bookingRepo.save(booking) + } + + @PostMapping("/{bookingId}/cancel") + @ResponseStatus(HttpStatus.NO_CONTENT) + @Transactional + fun cancel( + @PathVariable propertyId: UUID, + @PathVariable bookingId: UUID, + @AuthenticationPrincipal principal: MyPrincipal?, + @RequestBody request: BookingCancelRequest + ) { + requireActor(propertyId, principal) + val booking = requireBooking(propertyId, bookingId) + if (booking.status == BookingStatus.CHECKED_IN) { + val active = roomStayRepo.findActiveByBookingId(bookingId) + if (active.isNotEmpty()) { + throw ResponseStatusException(HttpStatus.CONFLICT, "Cannot cancel checked-in booking") + } + } + booking.status = BookingStatus.CANCELLED + if (request.reason != null) booking.notes = request.reason + booking.updatedAt = OffsetDateTime.now() + bookingRepo.save(booking) + } + + @PostMapping("/{bookingId}/no-show") + @ResponseStatus(HttpStatus.NO_CONTENT) + @Transactional + fun noShow( + @PathVariable propertyId: UUID, + @PathVariable bookingId: UUID, + @AuthenticationPrincipal principal: MyPrincipal?, + @RequestBody request: BookingNoShowRequest + ) { + requireActor(propertyId, principal) + val booking = requireBooking(propertyId, bookingId) + if (booking.status != BookingStatus.OPEN) { + throw ResponseStatusException(HttpStatus.CONFLICT, "Booking not open") + } + booking.status = BookingStatus.NO_SHOW + if (request.reason != null) booking.notes = request.reason + booking.updatedAt = OffsetDateTime.now() + bookingRepo.save(booking) + } + + private fun requireBooking(propertyId: UUID, bookingId: UUID): com.android.trisolarisserver.models.booking.Booking { + 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") + } + return booking + } + + private fun requireActor(propertyId: UUID, principal: MyPrincipal?): com.android.trisolarisserver.models.property.AppUser { + requirePrincipal(principal) + propertyAccess.requireMember(propertyId, principal!!.userId) + propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN, Role.MANAGER, Role.STAFF) + return appUserRepo.findById(principal.userId).orElseThrow { + ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found") + } + } + + private fun parseOffset(value: String?): OffsetDateTime? { + if (value.isNullOrBlank()) return null + return try { + OffsetDateTime.parse(value.trim()) + } catch (_: Exception) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid timestamp") + } + } + + private fun parseTransportMode(value: String): TransportMode { + return try { + TransportMode.valueOf(value) + } catch (_: IllegalArgumentException) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown transport mode") + } + } + + private fun isTransportModeAllowed( + property: com.android.trisolarisserver.models.property.Property, + mode: TransportMode + ): Boolean { + val allowed = when { + property.allowedTransportModes.isNotEmpty() -> property.allowedTransportModes + property.org.allowedTransportModes.isNotEmpty() -> property.org.allowedTransportModes + else -> TransportMode.entries.toSet() + } + return allowed.contains(mode) + } + + private fun requirePrincipal(principal: MyPrincipal?) { + if (principal == null) { + throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal") + } + } +} diff --git a/src/main/kotlin/com/android/trisolarisserver/controller/RoomStayFlow.kt b/src/main/kotlin/com/android/trisolarisserver/controller/RoomStayFlow.kt new file mode 100644 index 0000000..ee9b056 --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/controller/RoomStayFlow.kt @@ -0,0 +1,133 @@ +package com.android.trisolarisserver.controller + +import com.android.trisolarisserver.component.PropertyAccess +import com.android.trisolarisserver.controller.dto.RoomChangeRequest +import com.android.trisolarisserver.controller.dto.RoomChangeResponse +import com.android.trisolarisserver.models.room.RoomStay +import com.android.trisolarisserver.models.room.RoomStayChange +import com.android.trisolarisserver.models.property.Role +import com.android.trisolarisserver.repo.AppUserRepo +import com.android.trisolarisserver.repo.RoomRepo +import com.android.trisolarisserver.repo.RoomStayChangeRepo +import com.android.trisolarisserver.repo.RoomStayRepo +import com.android.trisolarisserver.security.MyPrincipal +import org.springframework.http.HttpStatus +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +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 java.time.OffsetDateTime +import java.util.UUID + +@RestController +@RequestMapping("/properties/{propertyId}/room-stays") +class RoomStayFlow( + private val propertyAccess: PropertyAccess, + private val roomStayRepo: RoomStayRepo, + private val roomStayChangeRepo: RoomStayChangeRepo, + private val roomRepo: RoomRepo, + private val appUserRepo: AppUserRepo +) { + + @PostMapping("/{roomStayId}/change-room") + @ResponseStatus(HttpStatus.CREATED) + @Transactional + fun changeRoom( + @PathVariable propertyId: UUID, + @PathVariable roomStayId: UUID, + @AuthenticationPrincipal principal: MyPrincipal?, + @RequestBody request: RoomChangeRequest + ): RoomChangeResponse { + val actor = requireActor(propertyId, principal) + + val stay = roomStayRepo.findById(roomStayId).orElseThrow { + ResponseStatusException(HttpStatus.NOT_FOUND, "Room stay not found") + } + if (stay.property.id != propertyId) { + throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room stay not found for property") + } + if (stay.toAt != null) { + throw ResponseStatusException(HttpStatus.CONFLICT, "Room stay already closed") + } + if (request.idempotencyKey.isBlank()) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "idempotencyKey required") + } + if (request.newRoomId == stay.room.id) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "New room is same as current") + } + + val existing = roomStayChangeRepo.findByRoomStayIdAndIdempotencyKey(roomStayId, request.idempotencyKey) + if (existing != null) { + return toResponse(existing) + } + + val newRoom = roomRepo.findByIdAndPropertyId(request.newRoomId, propertyId) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room not found") + if (!newRoom.active || newRoom.maintenance) { + throw ResponseStatusException(HttpStatus.CONFLICT, "Room not available") + } + val occupied = roomStayRepo.findActiveRoomIds(propertyId, listOf(request.newRoomId)) + if (occupied.isNotEmpty()) { + throw ResponseStatusException(HttpStatus.CONFLICT, "Room already occupied") + } + + val movedAt = parseOffset(request.movedAt) ?: OffsetDateTime.now() + + stay.toAt = movedAt + roomStayRepo.save(stay) + + val newStay = RoomStay( + property = stay.property, + booking = stay.booking, + room = newRoom, + fromAt = movedAt, + toAt = null, + createdBy = actor + ) + val savedNewStay = roomStayRepo.save(newStay) + + val change = RoomStayChange( + property = stay.property, + roomStay = stay, + newRoomStay = savedNewStay, + idempotencyKey = request.idempotencyKey + ) + val savedChange = roomStayChangeRepo.save(change) + return toResponse(savedChange) + } + + private fun toResponse(change: RoomStayChange): RoomChangeResponse { + return RoomChangeResponse( + oldRoomStayId = change.roomStay.id!!, + newRoomStayId = change.newRoomStay.id!!, + oldRoomId = change.roomStay.room.id!!, + newRoomId = change.newRoomStay.room.id!!, + movedAt = change.newRoomStay.fromAt.toString() + ) + } + + private fun parseOffset(value: String?): OffsetDateTime? { + if (value.isNullOrBlank()) return null + return try { + OffsetDateTime.parse(value.trim()) + } catch (_: Exception) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid timestamp") + } + } + + private fun requireActor(propertyId: UUID, principal: MyPrincipal?): com.android.trisolarisserver.models.property.AppUser { + if (principal == null) { + throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal") + } + propertyAccess.requireMember(propertyId, principal.userId) + propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN, Role.MANAGER, Role.STAFF) + return appUserRepo.findById(principal.userId).orElseThrow { + ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found") + } + } +} diff --git a/src/main/kotlin/com/android/trisolarisserver/controller/dto/BookingDtos.kt b/src/main/kotlin/com/android/trisolarisserver/controller/dto/BookingDtos.kt new file mode 100644 index 0000000..47968b5 --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/controller/dto/BookingDtos.kt @@ -0,0 +1,40 @@ +package com.android.trisolarisserver.controller.dto + +import java.util.UUID + +data class BookingCheckInRequest( + val roomIds: List, + val checkInAt: String? = null, + val transportMode: String? = null, + val transportVehicleNumber: String? = null, + val notes: String? = null +) + +data class BookingCheckOutRequest( + val checkOutAt: String? = null, + val notes: String? = null +) + +data class BookingCancelRequest( + val cancelledAt: String? = null, + val reason: String? = null +) + +data class BookingNoShowRequest( + val noShowAt: String? = null, + val reason: String? = null +) + +data class RoomChangeRequest( + val newRoomId: UUID, + val movedAt: String? = null, + val idempotencyKey: String +) + +data class RoomChangeResponse( + val oldRoomStayId: UUID, + val newRoomStayId: UUID, + val oldRoomId: UUID, + val newRoomId: UUID, + val movedAt: String +) diff --git a/src/main/kotlin/com/android/trisolarisserver/models/room/RoomStayChange.kt b/src/main/kotlin/com/android/trisolarisserver/models/room/RoomStayChange.kt new file mode 100644 index 0000000..2834c74 --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/models/room/RoomStayChange.kt @@ -0,0 +1,36 @@ +package com.android.trisolarisserver.models.room + +import com.android.trisolarisserver.models.property.Property +import jakarta.persistence.* +import java.time.OffsetDateTime +import java.util.UUID + +@Entity +@Table( + name = "room_stay_change", + uniqueConstraints = [UniqueConstraint(columnNames = ["room_stay_id", "idempotency_key"])] +) +class RoomStayChange( + @Id + @GeneratedValue + @Column(columnDefinition = "uuid") + val id: UUID? = null, + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "property_id", nullable = false) + var property: Property, + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "room_stay_id", nullable = false) + var roomStay: RoomStay, + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "new_room_stay_id", nullable = false) + var newRoomStay: RoomStay, + + @Column(name = "idempotency_key", nullable = false) + var idempotencyKey: String, + + @Column(name = "created_at", nullable = false, columnDefinition = "timestamptz") + val createdAt: OffsetDateTime = OffsetDateTime.now() +) diff --git a/src/main/kotlin/com/android/trisolarisserver/repo/RoomStayChangeRepo.kt b/src/main/kotlin/com/android/trisolarisserver/repo/RoomStayChangeRepo.kt new file mode 100644 index 0000000..fb35c0c --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/repo/RoomStayChangeRepo.kt @@ -0,0 +1,9 @@ +package com.android.trisolarisserver.repo + +import com.android.trisolarisserver.models.room.RoomStayChange +import org.springframework.data.jpa.repository.JpaRepository +import java.util.UUID + +interface RoomStayChangeRepo : JpaRepository { + fun findByRoomStayIdAndIdempotencyKey(roomStayId: UUID, idempotencyKey: String): RoomStayChange? +} diff --git a/src/main/kotlin/com/android/trisolarisserver/repo/RoomStayRepo.kt b/src/main/kotlin/com/android/trisolarisserver/repo/RoomStayRepo.kt index 84b48e8..c35e1f8 100644 --- a/src/main/kotlin/com/android/trisolarisserver/repo/RoomStayRepo.kt +++ b/src/main/kotlin/com/android/trisolarisserver/repo/RoomStayRepo.kt @@ -27,4 +27,24 @@ interface RoomStayRepo : JpaRepository { @Param("fromAt") fromAt: java.time.OffsetDateTime, @Param("toAt") toAt: java.time.OffsetDateTime ): List + + @Query(""" + select rs + from RoomStay rs + where rs.booking.id = :bookingId + and rs.toAt is null + """) + fun findActiveByBookingId(@Param("bookingId") bookingId: UUID): List + + @Query(""" + select rs.room.id + from RoomStay rs + where rs.property.id = :propertyId + and rs.room.id in :roomIds + and rs.toAt is null + """) + fun findActiveRoomIds( + @Param("propertyId") propertyId: UUID, + @Param("roomIds") roomIds: List + ): List }