From de7e2930975636cf306ad12be794f7efbb860992 Mon Sep 17 00:00:00 2001 From: androidlover5842 Date: Mon, 26 Jan 2026 15:09:20 +0530 Subject: [PATCH] build guest rating system --- .../controller/GuestRatings.kt | 136 ++++++++++++++++++ .../trisolarisserver/controller/Guests.kt | 25 +++- .../controller/dto/GuestRatingDtos.kt | 21 +++ .../controller/dto/OrgPropertyDtos.kt | 3 +- .../db/repo/GuestRatingRepo.kt | 27 ++++ .../models/booking/Booking.kt | 15 ++ .../models/booking/GuestRating.kt | 61 ++++++++ .../models/booking/GuestRatingScore.kt | 5 + 8 files changed, 287 insertions(+), 6 deletions(-) create mode 100644 src/main/kotlin/com/android/trisolarisserver/controller/GuestRatings.kt create mode 100644 src/main/kotlin/com/android/trisolarisserver/controller/dto/GuestRatingDtos.kt create mode 100644 src/main/kotlin/com/android/trisolarisserver/db/repo/GuestRatingRepo.kt create mode 100644 src/main/kotlin/com/android/trisolarisserver/models/booking/GuestRating.kt create mode 100644 src/main/kotlin/com/android/trisolarisserver/models/booking/GuestRatingScore.kt diff --git a/src/main/kotlin/com/android/trisolarisserver/controller/GuestRatings.kt b/src/main/kotlin/com/android/trisolarisserver/controller/GuestRatings.kt new file mode 100644 index 0000000..4987817 --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/controller/GuestRatings.kt @@ -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 { + 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") + } + } +} diff --git a/src/main/kotlin/com/android/trisolarisserver/controller/Guests.kt b/src/main/kotlin/com/android/trisolarisserver/controller/Guests.kt index fed9825..098590d 100644 --- a/src/main/kotlin/com/android/trisolarisserver/controller/Guests.kt +++ b/src/main/kotlin/com/android/trisolarisserver/controller/Guests.kt @@ -6,6 +6,7 @@ import com.android.trisolarisserver.controller.dto.GuestVehicleRequest import com.android.trisolarisserver.models.booking.Guest import com.android.trisolarisserver.models.booking.GuestVehicle 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.PropertyRepo import com.android.trisolarisserver.security.MyPrincipal @@ -21,7 +22,8 @@ class Guests( private val propertyAccess: PropertyAccess, private val propertyRepo: PropertyRepo, private val guestRepo: GuestRepo, - private val guestVehicleRepo: GuestVehicleRepo + private val guestVehicleRepo: GuestVehicleRepo, + private val guestRatingRepo: GuestRatingRepo ) { @GetMapping("/search") @@ -52,7 +54,7 @@ class Guests( val vehicle = guestVehicleRepo.findByOrgIdAndVehicleNumberIgnoreCase(orgId, vehicleNumber) if (vehicle != null) guests.add(vehicle.guest) } - return guests.toResponse(guestVehicleRepo) + return guests.toResponse(guestVehicleRepo, guestRatingRepo) } @PostMapping("/{guestId}/vehicles") @@ -85,7 +87,7 @@ class Guests( vehicleNumber = request.vehicleNumber.trim() ) guestVehicleRepo.save(vehicle) - return setOf(guest).toResponse(guestVehicleRepo).first() + return setOf(guest).toResponse(guestVehicleRepo, guestRatingRepo).first() } private fun requirePrincipal(principal: MyPrincipal?) { @@ -95,10 +97,22 @@ class Guests( } } -private fun Set.toResponse(guestVehicleRepo: GuestVehicleRepo): List { +private fun Set.toResponse( + guestVehicleRepo: GuestVehicleRepo, + guestRatingRepo: GuestRatingRepo +): List { val ids = this.mapNotNull { it.id } val vehicles = if (ids.isEmpty()) emptyList() else guestVehicleRepo.findByGuestIdIn(ids) 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 -> GuestResponse( id = guest.id!!, @@ -107,7 +121,8 @@ private fun Set.toResponse(guestVehicleRepo: GuestVehicleRepo): List + val vehicleNumbers: Set, + val averageScore: Double? ) data class GuestVehicleRequest( diff --git a/src/main/kotlin/com/android/trisolarisserver/db/repo/GuestRatingRepo.kt b/src/main/kotlin/com/android/trisolarisserver/db/repo/GuestRatingRepo.kt new file mode 100644 index 0000000..f87b1e2 --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/db/repo/GuestRatingRepo.kt @@ -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 { + fun findByGuestIdOrderByCreatedAtDesc(guestId: UUID): List + 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): List> +} diff --git a/src/main/kotlin/com/android/trisolarisserver/models/booking/Booking.kt b/src/main/kotlin/com/android/trisolarisserver/models/booking/Booking.kt index ba302f9..2f172e9 100644 --- a/src/main/kotlin/com/android/trisolarisserver/models/booking/Booking.kt +++ b/src/main/kotlin/com/android/trisolarisserver/models/booking/Booking.kt @@ -59,6 +59,21 @@ class Booking( @Column(name = "transport_vehicle_number") 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, @ManyToOne(fetch = FetchType.LAZY) diff --git a/src/main/kotlin/com/android/trisolarisserver/models/booking/GuestRating.kt b/src/main/kotlin/com/android/trisolarisserver/models/booking/GuestRating.kt new file mode 100644 index 0000000..626ae70 --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/models/booking/GuestRating.kt @@ -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() +) diff --git a/src/main/kotlin/com/android/trisolarisserver/models/booking/GuestRatingScore.kt b/src/main/kotlin/com/android/trisolarisserver/models/booking/GuestRatingScore.kt new file mode 100644 index 0000000..3ce1107 --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/models/booking/GuestRatingScore.kt @@ -0,0 +1,5 @@ +package com.android.trisolarisserver.models.booking + +enum class GuestRatingScore { + GOOD, OK, TROUBLE +}