Files
TrisolarisServer/src/main/kotlin/com/android/trisolarisserver/controller/Guests.kt
androidlover5842 bef941f417
All checks were successful
build-and-deploy / build-deploy (push) Successful in 35s
Accept SVG content type with charset
2026-01-29 10:04:37 +05:30

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