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
|
- POST /properties/{propertyId}/guests
|
||||||
- /properties/{propertyId}/guests/search?phone=... or ?vehicleNumber=...
|
- /properties/{propertyId}/guests/search?phone=... or ?vehicleNumber=...
|
||||||
- /properties/{propertyId}/guests/{guestId}/vehicles (add vehicle)
|
- /properties/{propertyId}/guests/{guestId}/vehicles (add vehicle)
|
||||||
|
- POST /properties/{propertyId}/guests/{guestId}/signature
|
||||||
|
- GET /properties/{propertyId}/guests/{guestId}/signature/file
|
||||||
|
|
||||||
Room stays
|
Room stays
|
||||||
- POST /properties/{propertyId}/room-stays/{roomStayId}/change-rate
|
- 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
|
package com.android.trisolarisserver.controller
|
||||||
|
|
||||||
import com.android.trisolarisserver.component.PropertyAccess
|
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.GuestCreateRequest
|
||||||
import com.android.trisolarisserver.controller.dto.GuestResponse
|
import com.android.trisolarisserver.controller.dto.GuestResponse
|
||||||
import com.android.trisolarisserver.controller.dto.GuestVehicleRequest
|
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.GuestVehicleRepo
|
||||||
import com.android.trisolarisserver.repo.PropertyRepo
|
import com.android.trisolarisserver.repo.PropertyRepo
|
||||||
import com.android.trisolarisserver.security.MyPrincipal
|
import com.android.trisolarisserver.security.MyPrincipal
|
||||||
|
import org.springframework.core.io.FileSystemResource
|
||||||
import org.springframework.http.HttpStatus
|
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.security.core.annotation.AuthenticationPrincipal
|
||||||
import org.springframework.web.bind.annotation.*
|
import org.springframework.web.bind.annotation.*
|
||||||
|
import org.springframework.web.multipart.MultipartFile
|
||||||
import org.springframework.web.server.ResponseStatusException
|
import org.springframework.web.server.ResponseStatusException
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
@@ -27,7 +32,8 @@ class Guests(
|
|||||||
private val guestRepo: GuestRepo,
|
private val guestRepo: GuestRepo,
|
||||||
private val bookingRepo: BookingRepo,
|
private val bookingRepo: BookingRepo,
|
||||||
private val guestVehicleRepo: GuestVehicleRepo,
|
private val guestVehicleRepo: GuestVehicleRepo,
|
||||||
private val guestRatingRepo: GuestRatingRepo
|
private val guestRatingRepo: GuestRatingRepo,
|
||||||
|
private val signatureStorage: GuestSignatureStorage
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
@@ -58,7 +64,7 @@ class Guests(
|
|||||||
booking.primaryGuest = existing
|
booking.primaryGuest = existing
|
||||||
booking.updatedAt = OffsetDateTime.now()
|
booking.updatedAt = OffsetDateTime.now()
|
||||||
bookingRepo.save(booking)
|
bookingRepo.save(booking)
|
||||||
return setOf(existing).toResponse(guestVehicleRepo, guestRatingRepo).first()
|
return setOf(existing).toResponse(propertyId, guestVehicleRepo, guestRatingRepo).first()
|
||||||
}
|
}
|
||||||
|
|
||||||
val now = OffsetDateTime.now()
|
val now = OffsetDateTime.now()
|
||||||
@@ -74,7 +80,7 @@ class Guests(
|
|||||||
booking.primaryGuest = saved
|
booking.primaryGuest = saved
|
||||||
booking.updatedAt = now
|
booking.updatedAt = now
|
||||||
bookingRepo.save(booking)
|
bookingRepo.save(booking)
|
||||||
return setOf(saved).toResponse(guestVehicleRepo, guestRatingRepo).first()
|
return setOf(saved).toResponse(propertyId, guestVehicleRepo, guestRatingRepo).first()
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/search")
|
@GetMapping("/search")
|
||||||
@@ -101,7 +107,7 @@ class Guests(
|
|||||||
val vehicle = guestVehicleRepo.findByPropertyIdAndVehicleNumberIgnoreCase(propertyId, vehicleNumber)
|
val vehicle = guestVehicleRepo.findByPropertyIdAndVehicleNumberIgnoreCase(propertyId, vehicleNumber)
|
||||||
if (vehicle != null) guests.add(vehicle.guest)
|
if (vehicle != null) guests.add(vehicle.guest)
|
||||||
}
|
}
|
||||||
return guests.toResponse(guestVehicleRepo, guestRatingRepo)
|
return guests.toResponse(propertyId, guestVehicleRepo, guestRatingRepo)
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/{guestId}/vehicles")
|
@PostMapping("/{guestId}/vehicles")
|
||||||
@@ -141,12 +147,56 @@ class Guests(
|
|||||||
)
|
)
|
||||||
guestVehicleRepo.save(vehicle)
|
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(
|
private fun Set<Guest>.toResponse(
|
||||||
|
propertyId: UUID,
|
||||||
guestVehicleRepo: GuestVehicleRepo,
|
guestVehicleRepo: GuestVehicleRepo,
|
||||||
guestRatingRepo: GuestRatingRepo
|
guestRatingRepo: GuestRatingRepo
|
||||||
): List<GuestResponse> {
|
): List<GuestResponse> {
|
||||||
@@ -169,6 +219,9 @@ private fun Set<Guest>.toResponse(
|
|||||||
phoneE164 = guest.phoneE164,
|
phoneE164 = guest.phoneE164,
|
||||||
nationality = guest.nationality,
|
nationality = guest.nationality,
|
||||||
addressText = guest.addressText,
|
addressText = guest.addressText,
|
||||||
|
signatureUrl = guest.signaturePath?.let {
|
||||||
|
"/properties/$propertyId/guests/${guest.id}/signature/file"
|
||||||
|
},
|
||||||
vehicleNumbers = vehiclesByGuest[guest.id]?.map { it.vehicleNumber }?.toSet() ?: emptySet(),
|
vehicleNumbers = vehiclesByGuest[guest.id]?.map { it.vehicleNumber }?.toSet() ?: emptySet(),
|
||||||
averageScore = averages[guest.id]
|
averageScore = averages[guest.id]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ data class GuestResponse(
|
|||||||
val phoneE164: String?,
|
val phoneE164: String?,
|
||||||
val nationality: String?,
|
val nationality: String?,
|
||||||
val addressText: String?,
|
val addressText: String?,
|
||||||
|
val signatureUrl: String?,
|
||||||
val vehicleNumbers: Set<String>,
|
val vehicleNumbers: Set<String>,
|
||||||
val averageScore: Double?
|
val averageScore: Double?
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ class Guest(
|
|||||||
@Column(name = "address_text")
|
@Column(name = "address_text")
|
||||||
var addressText: String? = null,
|
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")
|
@Column(name = "created_at", columnDefinition = "timestamptz")
|
||||||
val createdAt: OffsetDateTime = OffsetDateTime.now(),
|
val createdAt: OffsetDateTime = OffsetDateTime.now(),
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user