241 lines
10 KiB
Kotlin
241 lines
10 KiB
Kotlin
package com.android.trisolarisserver.controller
|
|
|
|
import com.android.trisolarisserver.component.PropertyAccess
|
|
import com.android.trisolarisserver.component.GuestSignatureStorage
|
|
import com.android.trisolarisserver.controller.dto.GuestResponse
|
|
import com.android.trisolarisserver.controller.dto.GuestUpdateRequest
|
|
import com.android.trisolarisserver.controller.dto.GuestVehicleRequest
|
|
import com.android.trisolarisserver.controller.dto.GuestVisitCountResponse
|
|
import com.android.trisolarisserver.models.booking.Guest
|
|
import com.android.trisolarisserver.models.booking.GuestVehicle
|
|
import com.android.trisolarisserver.db.repo.BookingRepo
|
|
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
|
|
import org.springframework.core.io.FileSystemResource
|
|
import org.springframework.http.HttpStatus
|
|
import org.springframework.http.MediaType
|
|
import org.springframework.http.ResponseEntity
|
|
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
|
import org.springframework.web.bind.annotation.*
|
|
import org.springframework.web.multipart.MultipartFile
|
|
import org.springframework.web.server.ResponseStatusException
|
|
import java.time.OffsetDateTime
|
|
import java.util.UUID
|
|
|
|
@RestController
|
|
@RequestMapping("/properties/{propertyId}/guests")
|
|
class Guests(
|
|
private val propertyAccess: PropertyAccess,
|
|
private val propertyRepo: PropertyRepo,
|
|
private val guestRepo: GuestRepo,
|
|
private val bookingRepo: BookingRepo,
|
|
private val guestVehicleRepo: GuestVehicleRepo,
|
|
private val guestRatingRepo: GuestRatingRepo,
|
|
private val signatureStorage: GuestSignatureStorage
|
|
) {
|
|
|
|
@PutMapping("/{guestId}")
|
|
fun updateGuest(
|
|
@PathVariable propertyId: UUID,
|
|
@PathVariable guestId: UUID,
|
|
@AuthenticationPrincipal principal: MyPrincipal?,
|
|
@RequestBody request: GuestUpdateRequest
|
|
): GuestResponse {
|
|
requireMember(propertyAccess, propertyId, principal)
|
|
val (_, guest) = requirePropertyGuest(propertyRepo, guestRepo, propertyId, guestId)
|
|
|
|
val phone = request.phoneE164?.trim()?.takeIf { it.isNotBlank() }
|
|
if (phone != null) {
|
|
val existing = guestRepo.findByPropertyIdAndPhoneE164(propertyId, phone)
|
|
if (existing != null && existing.id != guest.id) {
|
|
throw ResponseStatusException(HttpStatus.CONFLICT, "Phone number already exists")
|
|
}
|
|
guest.phoneE164 = phone
|
|
}
|
|
|
|
val name = request.name?.trim()?.ifBlank { null }
|
|
val nationality = request.nationality?.trim()?.ifBlank { null }
|
|
val address = request.addressText?.trim()?.ifBlank { null }
|
|
if (name != null) guest.name = name
|
|
if (nationality != null) guest.nationality = nationality
|
|
if (address != null) guest.addressText = address
|
|
guest.updatedAt = OffsetDateTime.now()
|
|
val saved = guestRepo.save(guest)
|
|
return setOf(saved).toResponse(propertyId, guestVehicleRepo, guestRatingRepo).first()
|
|
}
|
|
|
|
@GetMapping("/search")
|
|
fun search(
|
|
@PathVariable propertyId: UUID,
|
|
@AuthenticationPrincipal principal: MyPrincipal?,
|
|
@RequestParam(required = false) phone: String?,
|
|
@RequestParam(required = false) vehicleNumber: String?
|
|
): List<GuestResponse> {
|
|
requireMember(propertyAccess, propertyId, principal)
|
|
|
|
if (phone.isNullOrBlank() && vehicleNumber.isNullOrBlank()) {
|
|
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "phone or vehicleNumber required")
|
|
}
|
|
|
|
requireProperty(propertyRepo, propertyId)
|
|
|
|
val guests = mutableSetOf<Guest>()
|
|
if (!phone.isNullOrBlank()) {
|
|
val guest = guestRepo.findByPropertyIdAndPhoneE164(propertyId, phone)
|
|
if (guest != null) guests.add(guest)
|
|
}
|
|
if (!vehicleNumber.isNullOrBlank()) {
|
|
val vehicle = guestVehicleRepo.findByPropertyIdAndVehicleNumberIgnoreCase(propertyId, vehicleNumber)
|
|
if (vehicle != null) guests.add(vehicle.guest)
|
|
}
|
|
return guests.toResponse(propertyId, guestVehicleRepo, guestRatingRepo)
|
|
}
|
|
|
|
@GetMapping("/{guestId}")
|
|
fun getGuest(
|
|
@PathVariable propertyId: UUID,
|
|
@PathVariable guestId: UUID,
|
|
@AuthenticationPrincipal principal: MyPrincipal?
|
|
): GuestResponse {
|
|
requireMember(propertyAccess, propertyId, principal)
|
|
val (_, guest) = requirePropertyGuest(propertyRepo, guestRepo, propertyId, guestId)
|
|
return setOf(guest).toResponse(propertyId, guestVehicleRepo, guestRatingRepo).first()
|
|
}
|
|
|
|
@GetMapping("/visit-count")
|
|
fun getVisitCount(
|
|
@PathVariable propertyId: UUID,
|
|
@AuthenticationPrincipal principal: MyPrincipal?,
|
|
@RequestParam phone: String
|
|
): GuestVisitCountResponse {
|
|
requireMember(propertyAccess, propertyId, principal)
|
|
val phoneValue = phone.trim().ifBlank {
|
|
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "phone required")
|
|
}
|
|
requireProperty(propertyRepo, propertyId)
|
|
val guest = guestRepo.findByPropertyIdAndPhoneE164(propertyId, phoneValue)
|
|
val count = guest?.id?.let { bookingRepo.countByPrimaryGuestId(it) } ?: 0L
|
|
return GuestVisitCountResponse(guestId = guest?.id, bookingCount = count)
|
|
}
|
|
|
|
@PostMapping("/{guestId}/vehicles")
|
|
@ResponseStatus(HttpStatus.CREATED)
|
|
fun addVehicle(
|
|
@PathVariable propertyId: UUID,
|
|
@PathVariable guestId: UUID,
|
|
@AuthenticationPrincipal principal: MyPrincipal?,
|
|
@RequestBody request: GuestVehicleRequest
|
|
): GuestResponse {
|
|
requireMember(propertyAccess, propertyId, principal)
|
|
|
|
val (property, guest) = requirePropertyGuest(propertyRepo, guestRepo, propertyId, guestId)
|
|
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 != null && booking.primaryGuest?.id != guest.id) {
|
|
throw ResponseStatusException(HttpStatus.CONFLICT, "Booking linked to different guest")
|
|
}
|
|
|
|
val existing = guestVehicleRepo.findByPropertyIdAndVehicleNumberIgnoreCase(property.id!!, request.vehicleNumber)
|
|
if (existing != null) {
|
|
if (existing.guest.id != guest.id) {
|
|
throw ResponseStatusException(HttpStatus.CONFLICT, "Vehicle number already exists")
|
|
}
|
|
existing.booking = booking
|
|
guestVehicleRepo.save(existing)
|
|
} else {
|
|
val vehicle = GuestVehicle(
|
|
property = property,
|
|
guest = guest,
|
|
booking = booking,
|
|
vehicleNumber = request.vehicleNumber.trim()
|
|
)
|
|
guestVehicleRepo.save(vehicle)
|
|
}
|
|
return setOf(guest).toResponse(propertyId, guestVehicleRepo, guestRatingRepo).first()
|
|
}
|
|
|
|
@PostMapping("/{guestId}/signature")
|
|
@ResponseStatus(HttpStatus.CREATED)
|
|
fun uploadSignature(
|
|
@PathVariable propertyId: UUID,
|
|
@PathVariable guestId: UUID,
|
|
@AuthenticationPrincipal principal: MyPrincipal?,
|
|
@RequestPart("file") file: MultipartFile
|
|
): GuestResponse {
|
|
requireRole(propertyAccess, propertyId, principal, com.android.trisolarisserver.models.property.Role.ADMIN, com.android.trisolarisserver.models.property.Role.MANAGER)
|
|
if (file.isEmpty) {
|
|
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "File is empty")
|
|
}
|
|
val contentType = file.contentType ?: ""
|
|
if (!contentType.lowercase().startsWith("image/svg+xml")) {
|
|
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Only SVG allowed")
|
|
}
|
|
|
|
val (property, guest) = requirePropertyGuest(propertyRepo, guestRepo, propertyId, guestId)
|
|
val stored = signatureStorage.store(propertyId, guestId, file)
|
|
guest.signaturePath = stored.storagePath
|
|
guest.signatureUpdatedAt = OffsetDateTime.now()
|
|
guestRepo.save(guest)
|
|
return setOf(guest).toResponse(propertyId, guestVehicleRepo, guestRatingRepo).first()
|
|
}
|
|
|
|
@GetMapping("/{guestId}/signature/file")
|
|
fun downloadSignature(
|
|
@PathVariable propertyId: UUID,
|
|
@PathVariable guestId: UUID,
|
|
@AuthenticationPrincipal principal: MyPrincipal?
|
|
): ResponseEntity<FileSystemResource> {
|
|
requireMember(propertyAccess, propertyId, principal)
|
|
val (_, guest) = requirePropertyGuest(propertyRepo, guestRepo, propertyId, guestId)
|
|
val path = guest.signaturePath ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Signature not found")
|
|
val resource = FileSystemResource(signatureStorage.resolvePath(path))
|
|
if (!resource.exists()) {
|
|
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Signature not found")
|
|
}
|
|
return ResponseEntity.ok()
|
|
.contentType(MediaType.valueOf("image/svg+xml"))
|
|
.body(resource)
|
|
}
|
|
|
|
}
|
|
|
|
private fun Set<Guest>.toResponse(
|
|
propertyId: UUID,
|
|
guestVehicleRepo: GuestVehicleRepo,
|
|
guestRatingRepo: GuestRatingRepo
|
|
): List<GuestResponse> {
|
|
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!!,
|
|
name = guest.name,
|
|
phoneE164 = guest.phoneE164,
|
|
nationality = guest.nationality,
|
|
addressText = guest.addressText,
|
|
signatureUrl = guest.signaturePath?.let {
|
|
"/properties/$propertyId/guests/${guest.id}/signature/file"
|
|
},
|
|
vehicleNumbers = vehiclesByGuest[guest.id]?.map { it.vehicleNumber }?.toSet() ?: emptySet(),
|
|
averageScore = averages[guest.id]
|
|
)
|
|
}
|
|
}
|