build guest rating system

This commit is contained in:
androidlover5842
2026-01-26 15:09:20 +05:30
parent 31398d3822
commit de7e293097
8 changed files with 287 additions and 6 deletions

View File

@@ -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")
}
}
}

View File

@@ -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]
) )
} }
} }

View File

@@ -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?
)

View File

@@ -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(

View File

@@ -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>>
}

View File

@@ -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)

View File

@@ -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()
)

View File

@@ -0,0 +1,5 @@
package com.android.trisolarisserver.models.booking
enum class GuestRatingScore {
GOOD, OK, TROUBLE
}