From c5dc32d9afebcb839ab9616c8ed388551d72e250 Mon Sep 17 00:00:00 2001 From: androidlover5842 Date: Sat, 7 Feb 2026 21:45:55 +0530 Subject: [PATCH] Add admin/manager booking profile update API --- docs/API_REFERENCE.txt | 50 +++++++ .../controller/booking/BookingFlow.kt | 127 ++++++++++++++++++ 2 files changed, 177 insertions(+) diff --git a/docs/API_REFERENCE.txt b/docs/API_REFERENCE.txt index eb1f0f9..3d90232 100644 --- a/docs/API_REFERENCE.txt +++ b/docs/API_REFERENCE.txt @@ -1879,6 +1879,56 @@ BOOKING APIS - 409 Conflict (booking closed) +- Update booking profile API is this one: + + POST /properties/{propertyId}/bookings/{bookingId}/profile + + What it does: + + - Updates booking profile fields at any booking stage (OPEN, CHECKED_IN, CHECKED_OUT, CANCELLED, NO_SHOW). + - Supports partial updates for these fields only: + - transportMode + - childCount + - maleCount + - femaleCount + - fromCity + - toCity + - memberRelation + - If a provided field value is null, that field is cleared. + - Recomputes adultCount and totalGuestCount when any of maleCount/femaleCount/childCount is updated. + + Request body: + + { + "transportMode": "CAR", + "childCount": 1, + "maleCount": 1, + "femaleCount": 1, + "fromCity": "Varanasi", + "toCity": "Lucknow", + "memberRelation": "SELF" + } + + - Allowed roles: ADMIN, MANAGER + - superAdmin can also access. + + Error Codes + + - 400 Bad Request + - Invalid request body + - Unknown fields + - At least one field is required + - Unknown transport mode + - Transport mode disabled + - Unknown member relation + - childCount/maleCount/femaleCount must be integer or null + - childCount/maleCount/femaleCount must be >= 0 + - fromCity/toCity/memberRelation/transportMode type invalid + - 401 Unauthorized + - 403 Forbidden + - 404 Not Found + + - Booking check-out API is this one: POST /properties/{propertyId}/bookings/{bookingId}/check-out diff --git a/src/main/kotlin/com/android/trisolarisserver/controller/booking/BookingFlow.kt b/src/main/kotlin/com/android/trisolarisserver/controller/booking/BookingFlow.kt index 1870405..c9e61d2 100644 --- a/src/main/kotlin/com/android/trisolarisserver/controller/booking/BookingFlow.kt +++ b/src/main/kotlin/com/android/trisolarisserver/controller/booking/BookingFlow.kt @@ -53,6 +53,7 @@ import com.android.trisolarisserver.repo.room.RoomRepo import com.android.trisolarisserver.repo.room.RoomStayAuditLogRepo import com.android.trisolarisserver.repo.room.RoomStayRepo import com.android.trisolarisserver.security.MyPrincipal +import com.fasterxml.jackson.databind.JsonNode import jakarta.servlet.http.HttpServletResponse import org.springframework.http.HttpStatus import org.springframework.security.core.annotation.AuthenticationPrincipal @@ -635,6 +636,87 @@ class BookingFlow( bookingEvents.emit(propertyId, bookingId) } + @PostMapping("/{bookingId}/profile") + @ResponseStatus(HttpStatus.NO_CONTENT) + @Transactional + fun updateBookingProfile( + @PathVariable propertyId: UUID, + @PathVariable bookingId: UUID, + @AuthenticationPrincipal principal: MyPrincipal?, + @RequestBody request: JsonNode + ) { + requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER) + if (!request.isObject) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid request body") + } + + val allowedFields = setOf( + "transportMode", + "childCount", + "maleCount", + "femaleCount", + "fromCity", + "toCity", + "memberRelation" + ) + val fieldNames = request.fieldNames().asSequence().toList() + val unknownFields = fieldNames.filter { it !in allowedFields } + if (unknownFields.isNotEmpty()) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown fields: ${unknownFields.joinToString(",")}") + } + if (fieldNames.isEmpty()) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "At least one field is required") + } + + val booking = requireBooking(propertyId, bookingId) + + if (request.has("transportMode")) { + val transportNode = request.get("transportMode") + booking.transportMode = parseNullableTransportMode(booking, transportNode) + } + if (request.has("fromCity")) { + booking.fromCity = parseNullableText(request.get("fromCity"), "fromCity") + } + if (request.has("toCity")) { + booking.toCity = parseNullableText(request.get("toCity"), "toCity") + } + if (request.has("memberRelation")) { + booking.memberRelation = parseNullableMemberRelation(request.get("memberRelation")) + } + + val hasGuestCountPatch = request.has("maleCount") || request.has("femaleCount") || request.has("childCount") + if (hasGuestCountPatch) { + val maleCount = if (request.has("maleCount")) { + parseNullableNonNegativeInt(request.get("maleCount"), "maleCount") + } else { + booking.maleCount + } + val femaleCount = if (request.has("femaleCount")) { + parseNullableNonNegativeInt(request.get("femaleCount"), "femaleCount") + } else { + booking.femaleCount + } + val childCount = if (request.has("childCount")) { + parseNullableNonNegativeInt(request.get("childCount"), "childCount") + } else { + booking.childCount + } + val hasAnyGuestCount = maleCount != null || femaleCount != null || childCount != null + val adultCount = if (hasAnyGuestCount) (maleCount ?: 0) + (femaleCount ?: 0) else null + val totalGuestCount = if (hasAnyGuestCount) (maleCount ?: 0) + (femaleCount ?: 0) + (childCount ?: 0) else null + + booking.maleCount = maleCount + booking.femaleCount = femaleCount + booking.childCount = childCount + booking.adultCount = adultCount + booking.totalGuestCount = totalGuestCount + } + + booking.updatedAt = OffsetDateTime.now() + bookingRepo.save(booking) + bookingEvents.emit(propertyId, bookingId) + } + @PostMapping("/{bookingId}/check-out") @ResponseStatus(HttpStatus.NO_CONTENT) @Transactional @@ -952,6 +1034,23 @@ class BookingFlow( } } + private fun parseNullableTransportMode( + booking: com.android.trisolarisserver.models.booking.Booking, + node: JsonNode? + ): TransportMode? { + if (node == null || node.isNull) return null + if (!node.isTextual) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "transportMode must be string or null") + } + val raw = node.asText().trim() + if (raw.isBlank()) return null + val mode = parseTransportMode(raw.uppercase()) + if (!isTransportModeAllowed(booking.property, mode)) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Transport mode disabled") + } + return mode + } + private fun parseMemberRelation(value: String?): MemberRelation? { if (value.isNullOrBlank()) return null return try { @@ -961,6 +1060,34 @@ class BookingFlow( } } + private fun parseNullableMemberRelation(node: JsonNode?): MemberRelation? { + if (node == null || node.isNull) return null + if (!node.isTextual) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "memberRelation must be string or null") + } + return parseMemberRelation(node.asText()) + } + + private fun parseNullableText(node: JsonNode?, fieldName: String): String? { + if (node == null || node.isNull) return null + if (!node.isTextual) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "$fieldName must be string or null") + } + return node.asText().trim().ifBlank { null } + } + + private fun parseNullableNonNegativeInt(node: JsonNode?, fieldName: String): Int? { + if (node == null || node.isNull) return null + if (!node.isIntegralNumber) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "$fieldName must be integer or null") + } + val value = node.asInt() + if (value < 0) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "$fieldName must be >= 0") + } + return value + } + private fun parseRateSource(value: String?): RateSource? { if (value.isNullOrBlank()) return null return try {