Add guest signature upload/download
All checks were successful
build-and-deploy / build-deploy (push) Successful in 34s
All checks were successful
build-and-deploy / build-deploy (push) Successful in 34s
This commit is contained in:
@@ -119,6 +119,8 @@ Guest APIs
|
||||
- POST /properties/{propertyId}/guests
|
||||
- /properties/{propertyId}/guests/search?phone=... or ?vehicleNumber=...
|
||||
- /properties/{propertyId}/guests/{guestId}/vehicles (add vehicle)
|
||||
- POST /properties/{propertyId}/guests/{guestId}/signature
|
||||
- GET /properties/{propertyId}/guests/{guestId}/signature/file
|
||||
|
||||
Room stays
|
||||
- POST /properties/{propertyId}/room-stays/{roomStayId}/change-rate
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.android.trisolarisserver.component
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.web.multipart.MultipartFile
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.Paths
|
||||
import java.util.UUID
|
||||
|
||||
data class StoredSignature(
|
||||
val storagePath: String,
|
||||
val sizeBytes: Long
|
||||
)
|
||||
|
||||
@Component
|
||||
class GuestSignatureStorage(
|
||||
@Value("\${storage.documents.root}") private val root: String
|
||||
) {
|
||||
init {
|
||||
val rootPath = Paths.get(root)
|
||||
Files.createDirectories(rootPath)
|
||||
if (!Files.isWritable(rootPath)) {
|
||||
throw IllegalStateException("Guest signature root not writable: $root")
|
||||
}
|
||||
}
|
||||
|
||||
fun store(propertyId: UUID, guestId: UUID, file: MultipartFile): StoredSignature {
|
||||
val dir = Paths.get(root, "guests", propertyId.toString(), guestId.toString())
|
||||
Files.createDirectories(dir)
|
||||
val path = dir.resolve("signature.svg")
|
||||
file.inputStream.use { input ->
|
||||
Files.newOutputStream(path).use { output -> input.copyTo(output) }
|
||||
}
|
||||
return StoredSignature(
|
||||
storagePath = path.toString(),
|
||||
sizeBytes = Files.size(path)
|
||||
)
|
||||
}
|
||||
|
||||
fun resolvePath(storagePath: String): Path {
|
||||
return Paths.get(storagePath)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.android.trisolarisserver.controller
|
||||
|
||||
import com.android.trisolarisserver.component.PropertyAccess
|
||||
import com.android.trisolarisserver.component.GuestSignatureStorage
|
||||
import com.android.trisolarisserver.controller.dto.GuestCreateRequest
|
||||
import com.android.trisolarisserver.controller.dto.GuestResponse
|
||||
import com.android.trisolarisserver.controller.dto.GuestVehicleRequest
|
||||
@@ -12,9 +13,13 @@ 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
|
||||
@@ -27,7 +32,8 @@ class Guests(
|
||||
private val guestRepo: GuestRepo,
|
||||
private val bookingRepo: BookingRepo,
|
||||
private val guestVehicleRepo: GuestVehicleRepo,
|
||||
private val guestRatingRepo: GuestRatingRepo
|
||||
private val guestRatingRepo: GuestRatingRepo,
|
||||
private val signatureStorage: GuestSignatureStorage
|
||||
) {
|
||||
|
||||
@PostMapping
|
||||
@@ -58,7 +64,7 @@ class Guests(
|
||||
booking.primaryGuest = existing
|
||||
booking.updatedAt = OffsetDateTime.now()
|
||||
bookingRepo.save(booking)
|
||||
return setOf(existing).toResponse(guestVehicleRepo, guestRatingRepo).first()
|
||||
return setOf(existing).toResponse(propertyId, guestVehicleRepo, guestRatingRepo).first()
|
||||
}
|
||||
|
||||
val now = OffsetDateTime.now()
|
||||
@@ -74,7 +80,7 @@ class Guests(
|
||||
booking.primaryGuest = saved
|
||||
booking.updatedAt = now
|
||||
bookingRepo.save(booking)
|
||||
return setOf(saved).toResponse(guestVehicleRepo, guestRatingRepo).first()
|
||||
return setOf(saved).toResponse(propertyId, guestVehicleRepo, guestRatingRepo).first()
|
||||
}
|
||||
|
||||
@GetMapping("/search")
|
||||
@@ -101,7 +107,7 @@ class Guests(
|
||||
val vehicle = guestVehicleRepo.findByPropertyIdAndVehicleNumberIgnoreCase(propertyId, vehicleNumber)
|
||||
if (vehicle != null) guests.add(vehicle.guest)
|
||||
}
|
||||
return guests.toResponse(guestVehicleRepo, guestRatingRepo)
|
||||
return guests.toResponse(propertyId, guestVehicleRepo, guestRatingRepo)
|
||||
}
|
||||
|
||||
@PostMapping("/{guestId}/vehicles")
|
||||
@@ -141,12 +147,56 @@ class Guests(
|
||||
)
|
||||
guestVehicleRepo.save(vehicle)
|
||||
}
|
||||
return setOf(guest).toResponse(guestVehicleRepo, guestRatingRepo).first()
|
||||
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.equals("image/svg+xml", ignoreCase = true)) {
|
||||
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> {
|
||||
@@ -169,6 +219,9 @@ private fun Set<Guest>.toResponse(
|
||||
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]
|
||||
)
|
||||
|
||||
@@ -45,6 +45,7 @@ data class GuestResponse(
|
||||
val phoneE164: String?,
|
||||
val nationality: String?,
|
||||
val addressText: String?,
|
||||
val signatureUrl: String?,
|
||||
val vehicleNumbers: Set<String>,
|
||||
val averageScore: Double?
|
||||
)
|
||||
|
||||
@@ -29,6 +29,12 @@ class Guest(
|
||||
@Column(name = "address_text")
|
||||
var addressText: String? = null,
|
||||
|
||||
@Column(name = "signature_path")
|
||||
var signaturePath: String? = null,
|
||||
|
||||
@Column(name = "signature_updated_at", columnDefinition = "timestamptz")
|
||||
var signatureUpdatedAt: OffsetDateTime? = null,
|
||||
|
||||
@Column(name = "created_at", columnDefinition = "timestamptz")
|
||||
val createdAt: OffsetDateTime = OffsetDateTime.now(),
|
||||
|
||||
|
||||
Reference in New Issue
Block a user