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 { requireMember(propertyAccess, propertyId, principal) if (phone.isNullOrBlank() && vehicleNumber.isNullOrBlank()) { throw ResponseStatusException(HttpStatus.BAD_REQUEST, "phone or vehicleNumber required") } requireProperty(propertyRepo, propertyId) val guests = mutableSetOf() 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 { 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.toResponse( propertyId: UUID, 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!!, 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] ) } }