build guest rating system
This commit is contained in:
@@ -0,0 +1,136 @@
|
|||||||
|
package com.android.trisolarisserver.controller
|
||||||
|
|
||||||
|
import com.android.trisolarisserver.component.PropertyAccess
|
||||||
|
import com.android.trisolarisserver.controller.dto.GuestRatingCreateRequest
|
||||||
|
import com.android.trisolarisserver.controller.dto.GuestRatingResponse
|
||||||
|
import com.android.trisolarisserver.db.repo.BookingRepo
|
||||||
|
import com.android.trisolarisserver.db.repo.GuestRatingRepo
|
||||||
|
import com.android.trisolarisserver.db.repo.GuestRepo
|
||||||
|
import com.android.trisolarisserver.repo.AppUserRepo
|
||||||
|
import com.android.trisolarisserver.repo.PropertyRepo
|
||||||
|
import com.android.trisolarisserver.models.booking.GuestRating
|
||||||
|
import com.android.trisolarisserver.models.booking.GuestRatingScore
|
||||||
|
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.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.util.UUID
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/properties/{propertyId}/guests/{guestId}/ratings")
|
||||||
|
class GuestRatings(
|
||||||
|
private val propertyAccess: PropertyAccess,
|
||||||
|
private val propertyRepo: PropertyRepo,
|
||||||
|
private val guestRepo: GuestRepo,
|
||||||
|
private val bookingRepo: BookingRepo,
|
||||||
|
private val guestRatingRepo: GuestRatingRepo,
|
||||||
|
private val appUserRepo: AppUserRepo
|
||||||
|
) {
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
|
fun create(
|
||||||
|
@PathVariable propertyId: UUID,
|
||||||
|
@PathVariable guestId: UUID,
|
||||||
|
@AuthenticationPrincipal principal: MyPrincipal?,
|
||||||
|
@RequestBody request: GuestRatingCreateRequest
|
||||||
|
): GuestRatingResponse {
|
||||||
|
requirePrincipal(principal)
|
||||||
|
propertyAccess.requireMember(propertyId, principal!!.userId)
|
||||||
|
|
||||||
|
val property = propertyRepo.findById(propertyId).orElseThrow {
|
||||||
|
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
|
||||||
|
}
|
||||||
|
val guest = guestRepo.findById(guestId).orElseThrow {
|
||||||
|
ResponseStatusException(HttpStatus.NOT_FOUND, "Guest not found")
|
||||||
|
}
|
||||||
|
if (guest.org.id != property.org.id) {
|
||||||
|
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Guest not in property org")
|
||||||
|
}
|
||||||
|
|
||||||
|
val booking = bookingRepo.findById(request.bookingId).orElseThrow {
|
||||||
|
ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found")
|
||||||
|
}
|
||||||
|
if (booking.property.id != property.id) {
|
||||||
|
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Booking not in property")
|
||||||
|
}
|
||||||
|
if (booking.primaryGuest?.id != guest.id) {
|
||||||
|
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Booking not linked to guest")
|
||||||
|
}
|
||||||
|
if (guestRatingRepo.existsByGuestIdAndBookingId(guest.id!!, booking.id!!)) {
|
||||||
|
throw ResponseStatusException(HttpStatus.CONFLICT, "Rating already exists for booking")
|
||||||
|
}
|
||||||
|
|
||||||
|
val score = parseScore(request.score)
|
||||||
|
val rating = GuestRating(
|
||||||
|
org = property.org,
|
||||||
|
property = property,
|
||||||
|
guest = guest,
|
||||||
|
booking = booking,
|
||||||
|
score = score,
|
||||||
|
notes = request.notes?.trim(),
|
||||||
|
createdBy = appUserRepo.findById(principal.userId).orElse(null)
|
||||||
|
)
|
||||||
|
guestRatingRepo.save(rating)
|
||||||
|
return rating.toResponse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
fun list(
|
||||||
|
@PathVariable propertyId: UUID,
|
||||||
|
@PathVariable guestId: UUID,
|
||||||
|
@AuthenticationPrincipal principal: MyPrincipal?
|
||||||
|
): List<GuestRatingResponse> {
|
||||||
|
requirePrincipal(principal)
|
||||||
|
propertyAccess.requireMember(propertyId, principal!!.userId)
|
||||||
|
|
||||||
|
val property = propertyRepo.findById(propertyId).orElseThrow {
|
||||||
|
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
|
||||||
|
}
|
||||||
|
val guest = guestRepo.findById(guestId).orElseThrow {
|
||||||
|
ResponseStatusException(HttpStatus.NOT_FOUND, "Guest not found")
|
||||||
|
}
|
||||||
|
if (guest.org.id != property.org.id) {
|
||||||
|
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Guest not in property org")
|
||||||
|
}
|
||||||
|
|
||||||
|
return guestRatingRepo.findByGuestIdOrderByCreatedAtDesc(guestId).map { it.toResponse() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseScore(raw: String): GuestRatingScore {
|
||||||
|
val normalized = raw.trim().uppercase()
|
||||||
|
return when (normalized) {
|
||||||
|
"1", "GOOD" -> GuestRatingScore.GOOD
|
||||||
|
"2", "OK" -> GuestRatingScore.OK
|
||||||
|
"3", "TROUBLE", "TROUBLEMAKER", "TROUBLE-MAKER" -> GuestRatingScore.TROUBLE
|
||||||
|
else -> throw ResponseStatusException(HttpStatus.BAD_REQUEST, "score must be GOOD/OK/TROUBLE or 1/2/3")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun GuestRating.toResponse(): GuestRatingResponse {
|
||||||
|
return GuestRatingResponse(
|
||||||
|
id = id!!,
|
||||||
|
orgId = org.id!!,
|
||||||
|
propertyId = property.id!!,
|
||||||
|
guestId = guest.id!!,
|
||||||
|
bookingId = booking.id!!,
|
||||||
|
score = score.name,
|
||||||
|
notes = notes,
|
||||||
|
createdAt = createdAt.toString(),
|
||||||
|
createdByUserId = createdBy?.id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requirePrincipal(principal: MyPrincipal?) {
|
||||||
|
if (principal == null) {
|
||||||
|
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import com.android.trisolarisserver.controller.dto.GuestVehicleRequest
|
|||||||
import com.android.trisolarisserver.models.booking.Guest
|
import com.android.trisolarisserver.models.booking.Guest
|
||||||
import com.android.trisolarisserver.models.booking.GuestVehicle
|
import com.android.trisolarisserver.models.booking.GuestVehicle
|
||||||
import com.android.trisolarisserver.db.repo.GuestRepo
|
import com.android.trisolarisserver.db.repo.GuestRepo
|
||||||
|
import com.android.trisolarisserver.db.repo.GuestRatingRepo
|
||||||
import com.android.trisolarisserver.repo.GuestVehicleRepo
|
import com.android.trisolarisserver.repo.GuestVehicleRepo
|
||||||
import com.android.trisolarisserver.repo.PropertyRepo
|
import com.android.trisolarisserver.repo.PropertyRepo
|
||||||
import com.android.trisolarisserver.security.MyPrincipal
|
import com.android.trisolarisserver.security.MyPrincipal
|
||||||
@@ -21,7 +22,8 @@ class Guests(
|
|||||||
private val propertyAccess: PropertyAccess,
|
private val propertyAccess: PropertyAccess,
|
||||||
private val propertyRepo: PropertyRepo,
|
private val propertyRepo: PropertyRepo,
|
||||||
private val guestRepo: GuestRepo,
|
private val guestRepo: GuestRepo,
|
||||||
private val guestVehicleRepo: GuestVehicleRepo
|
private val guestVehicleRepo: GuestVehicleRepo,
|
||||||
|
private val guestRatingRepo: GuestRatingRepo
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@GetMapping("/search")
|
@GetMapping("/search")
|
||||||
@@ -52,7 +54,7 @@ class Guests(
|
|||||||
val vehicle = guestVehicleRepo.findByOrgIdAndVehicleNumberIgnoreCase(orgId, vehicleNumber)
|
val vehicle = guestVehicleRepo.findByOrgIdAndVehicleNumberIgnoreCase(orgId, vehicleNumber)
|
||||||
if (vehicle != null) guests.add(vehicle.guest)
|
if (vehicle != null) guests.add(vehicle.guest)
|
||||||
}
|
}
|
||||||
return guests.toResponse(guestVehicleRepo)
|
return guests.toResponse(guestVehicleRepo, guestRatingRepo)
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/{guestId}/vehicles")
|
@PostMapping("/{guestId}/vehicles")
|
||||||
@@ -85,7 +87,7 @@ class Guests(
|
|||||||
vehicleNumber = request.vehicleNumber.trim()
|
vehicleNumber = request.vehicleNumber.trim()
|
||||||
)
|
)
|
||||||
guestVehicleRepo.save(vehicle)
|
guestVehicleRepo.save(vehicle)
|
||||||
return setOf(guest).toResponse(guestVehicleRepo).first()
|
return setOf(guest).toResponse(guestVehicleRepo, guestRatingRepo).first()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun requirePrincipal(principal: MyPrincipal?) {
|
private fun requirePrincipal(principal: MyPrincipal?) {
|
||||||
@@ -95,10 +97,22 @@ class Guests(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Set<Guest>.toResponse(guestVehicleRepo: GuestVehicleRepo): List<GuestResponse> {
|
private fun Set<Guest>.toResponse(
|
||||||
|
guestVehicleRepo: GuestVehicleRepo,
|
||||||
|
guestRatingRepo: GuestRatingRepo
|
||||||
|
): List<GuestResponse> {
|
||||||
val ids = this.mapNotNull { it.id }
|
val ids = this.mapNotNull { it.id }
|
||||||
val vehicles = if (ids.isEmpty()) emptyList() else guestVehicleRepo.findByGuestIdIn(ids)
|
val vehicles = if (ids.isEmpty()) emptyList() else guestVehicleRepo.findByGuestIdIn(ids)
|
||||||
val vehiclesByGuest = vehicles.groupBy { it.guest.id }
|
val vehiclesByGuest = vehicles.groupBy { it.guest.id }
|
||||||
|
val averages = if (ids.isEmpty()) {
|
||||||
|
emptyMap()
|
||||||
|
} else {
|
||||||
|
guestRatingRepo.findAverageScoreByGuestIds(ids).associate { row ->
|
||||||
|
val guestId = row[0] as UUID
|
||||||
|
val avg = row[1] as Double
|
||||||
|
guestId to avg
|
||||||
|
}
|
||||||
|
}
|
||||||
return this.map { guest ->
|
return this.map { guest ->
|
||||||
GuestResponse(
|
GuestResponse(
|
||||||
id = guest.id!!,
|
id = guest.id!!,
|
||||||
@@ -107,7 +121,8 @@ private fun Set<Guest>.toResponse(guestVehicleRepo: GuestVehicleRepo): List<Gues
|
|||||||
phoneE164 = guest.phoneE164,
|
phoneE164 = guest.phoneE164,
|
||||||
nationality = guest.nationality,
|
nationality = guest.nationality,
|
||||||
addressText = guest.addressText,
|
addressText = guest.addressText,
|
||||||
vehicleNumbers = vehiclesByGuest[guest.id]?.map { it.vehicleNumber }?.toSet() ?: emptySet()
|
vehicleNumbers = vehiclesByGuest[guest.id]?.map { it.vehicleNumber }?.toSet() ?: emptySet(),
|
||||||
|
averageScore = averages[guest.id]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.android.trisolarisserver.controller.dto
|
||||||
|
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
data class GuestRatingCreateRequest(
|
||||||
|
val bookingId: UUID,
|
||||||
|
val score: String,
|
||||||
|
val notes: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class GuestRatingResponse(
|
||||||
|
val id: UUID,
|
||||||
|
val orgId: UUID,
|
||||||
|
val propertyId: UUID,
|
||||||
|
val guestId: UUID,
|
||||||
|
val bookingId: UUID,
|
||||||
|
val score: String,
|
||||||
|
val notes: String?,
|
||||||
|
val createdAt: String,
|
||||||
|
val createdByUserId: UUID?
|
||||||
|
)
|
||||||
@@ -60,7 +60,8 @@ data class GuestResponse(
|
|||||||
val phoneE164: String?,
|
val phoneE164: String?,
|
||||||
val nationality: String?,
|
val nationality: String?,
|
||||||
val addressText: String?,
|
val addressText: String?,
|
||||||
val vehicleNumbers: Set<String>
|
val vehicleNumbers: Set<String>,
|
||||||
|
val averageScore: Double?
|
||||||
)
|
)
|
||||||
|
|
||||||
data class GuestVehicleRequest(
|
data class GuestVehicleRequest(
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.android.trisolarisserver.db.repo
|
||||||
|
|
||||||
|
import com.android.trisolarisserver.models.booking.GuestRating
|
||||||
|
import org.springframework.data.jpa.repository.Query
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
|
import org.springframework.data.repository.query.Param
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
interface GuestRatingRepo : JpaRepository<GuestRating, UUID> {
|
||||||
|
fun findByGuestIdOrderByCreatedAtDesc(guestId: UUID): List<GuestRating>
|
||||||
|
fun existsByGuestIdAndBookingId(guestId: UUID, bookingId: UUID): Boolean
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
select gr.guest.id,
|
||||||
|
avg(case gr.score
|
||||||
|
when com.android.trisolarisserver.models.booking.GuestRatingScore.GOOD then 3
|
||||||
|
when com.android.trisolarisserver.models.booking.GuestRatingScore.OK then 2
|
||||||
|
when com.android.trisolarisserver.models.booking.GuestRatingScore.TROUBLE then 1
|
||||||
|
else null end)
|
||||||
|
from GuestRating gr
|
||||||
|
where gr.guest.id in :guestIds
|
||||||
|
group by gr.guest.id
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
fun findAverageScoreByGuestIds(@Param("guestIds") guestIds: Collection<UUID>): List<Array<Any>>
|
||||||
|
}
|
||||||
@@ -59,6 +59,21 @@ class Booking(
|
|||||||
@Column(name = "transport_vehicle_number")
|
@Column(name = "transport_vehicle_number")
|
||||||
var transportVehicleNumber: String? = null,
|
var transportVehicleNumber: String? = null,
|
||||||
|
|
||||||
|
@Column(name = "adult_count")
|
||||||
|
var adultCount: Int? = null,
|
||||||
|
|
||||||
|
@Column(name = "child_count")
|
||||||
|
var childCount: Int? = null,
|
||||||
|
|
||||||
|
@Column(name = "male_count")
|
||||||
|
var maleCount: Int? = null,
|
||||||
|
|
||||||
|
@Column(name = "female_count")
|
||||||
|
var femaleCount: Int? = null,
|
||||||
|
|
||||||
|
@Column(name = "total_guest_count")
|
||||||
|
var totalGuestCount: Int? = null,
|
||||||
|
|
||||||
var notes: String? = null,
|
var notes: String? = null,
|
||||||
|
|
||||||
@ManyToOne(fetch = FetchType.LAZY)
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package com.android.trisolarisserver.models.booking
|
||||||
|
|
||||||
|
import com.android.trisolarisserver.models.property.AppUser
|
||||||
|
import com.android.trisolarisserver.models.property.Organization
|
||||||
|
import com.android.trisolarisserver.models.property.Property
|
||||||
|
import jakarta.persistence.Column
|
||||||
|
import jakarta.persistence.Entity
|
||||||
|
import jakarta.persistence.EnumType
|
||||||
|
import jakarta.persistence.Enumerated
|
||||||
|
import jakarta.persistence.FetchType
|
||||||
|
import jakarta.persistence.GeneratedValue
|
||||||
|
import jakarta.persistence.Id
|
||||||
|
import jakarta.persistence.JoinColumn
|
||||||
|
import jakarta.persistence.ManyToOne
|
||||||
|
import jakarta.persistence.Table
|
||||||
|
import jakarta.persistence.UniqueConstraint
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(
|
||||||
|
name = "guest_rating",
|
||||||
|
uniqueConstraints = [
|
||||||
|
UniqueConstraint(columnNames = ["guest_id", "booking_id"])
|
||||||
|
]
|
||||||
|
)
|
||||||
|
class GuestRating(
|
||||||
|
@Id
|
||||||
|
@GeneratedValue
|
||||||
|
@Column(columnDefinition = "uuid")
|
||||||
|
val id: UUID? = null,
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||||
|
@JoinColumn(name = "org_id", nullable = false)
|
||||||
|
var org: Organization,
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||||
|
@JoinColumn(name = "property_id", nullable = false)
|
||||||
|
var property: Property,
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||||
|
@JoinColumn(name = "guest_id", nullable = false)
|
||||||
|
var guest: Guest,
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||||
|
@JoinColumn(name = "booking_id", nullable = false)
|
||||||
|
var booking: Booking,
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(nullable = false)
|
||||||
|
var score: GuestRatingScore,
|
||||||
|
|
||||||
|
var notes: String? = null,
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "created_by")
|
||||||
|
var createdBy: AppUser? = null,
|
||||||
|
|
||||||
|
@Column(name = "created_at", columnDefinition = "timestamptz")
|
||||||
|
val createdAt: OffsetDateTime = OffsetDateTime.now()
|
||||||
|
)
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package com.android.trisolarisserver.models.booking
|
||||||
|
|
||||||
|
enum class GuestRatingScore {
|
||||||
|
GOOD, OK, TROUBLE
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user