Add global image tags and tag assignment
All checks were successful
build-and-deploy / build-deploy (push) Successful in 33s
All checks were successful
build-and-deploy / build-deploy (push) Successful in 33s
This commit is contained in:
@@ -0,0 +1,110 @@
|
|||||||
|
package com.android.trisolarisserver.controller
|
||||||
|
|
||||||
|
import com.android.trisolarisserver.controller.dto.RoomImageTagResponse
|
||||||
|
import com.android.trisolarisserver.controller.dto.RoomImageTagUpsertRequest
|
||||||
|
import com.android.trisolarisserver.models.room.RoomImageTag
|
||||||
|
import com.android.trisolarisserver.repo.AppUserRepo
|
||||||
|
import com.android.trisolarisserver.repo.RoomImageRepo
|
||||||
|
import com.android.trisolarisserver.repo.RoomImageTagRepo
|
||||||
|
import com.android.trisolarisserver.security.MyPrincipal
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.ResponseStatus
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import org.springframework.web.server.ResponseStatusException
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/image-tags")
|
||||||
|
class RoomImageTags(
|
||||||
|
private val roomImageTagRepo: RoomImageTagRepo,
|
||||||
|
private val roomImageRepo: RoomImageRepo,
|
||||||
|
private val appUserRepo: AppUserRepo
|
||||||
|
) {
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
fun listTags(
|
||||||
|
@AuthenticationPrincipal principal: MyPrincipal?
|
||||||
|
): List<RoomImageTagResponse> {
|
||||||
|
requirePrincipal(principal)
|
||||||
|
return roomImageTagRepo.findAllByOrderByName().map { it.toResponse() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
|
fun createTag(
|
||||||
|
@AuthenticationPrincipal principal: MyPrincipal?,
|
||||||
|
@RequestBody request: RoomImageTagUpsertRequest
|
||||||
|
): RoomImageTagResponse {
|
||||||
|
requireSuperAdmin(principal)
|
||||||
|
if (roomImageTagRepo.existsByName(request.name)) {
|
||||||
|
throw ResponseStatusException(HttpStatus.CONFLICT, "Tag already exists")
|
||||||
|
}
|
||||||
|
val tag = RoomImageTag(name = request.name)
|
||||||
|
return roomImageTagRepo.save(tag).toResponse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{tagId}")
|
||||||
|
fun updateTag(
|
||||||
|
@PathVariable tagId: UUID,
|
||||||
|
@AuthenticationPrincipal principal: MyPrincipal?,
|
||||||
|
@RequestBody request: RoomImageTagUpsertRequest
|
||||||
|
): RoomImageTagResponse {
|
||||||
|
requireSuperAdmin(principal)
|
||||||
|
val tag = roomImageTagRepo.findById(tagId).orElse(null)
|
||||||
|
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Tag not found")
|
||||||
|
if (roomImageTagRepo.existsByNameAndIdNot(request.name, tagId)) {
|
||||||
|
throw ResponseStatusException(HttpStatus.CONFLICT, "Tag already exists")
|
||||||
|
}
|
||||||
|
tag.name = request.name
|
||||||
|
return roomImageTagRepo.save(tag).toResponse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{tagId}")
|
||||||
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
|
fun deleteTag(
|
||||||
|
@PathVariable tagId: UUID,
|
||||||
|
@AuthenticationPrincipal principal: MyPrincipal?
|
||||||
|
) {
|
||||||
|
requireSuperAdmin(principal)
|
||||||
|
if (roomImageRepo.existsByTagsId(tagId)) {
|
||||||
|
throw ResponseStatusException(HttpStatus.CONFLICT, "Tag is used by images")
|
||||||
|
}
|
||||||
|
val tag = roomImageTagRepo.findById(tagId).orElse(null)
|
||||||
|
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Tag not found")
|
||||||
|
roomImageTagRepo.delete(tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requirePrincipal(principal: MyPrincipal?) {
|
||||||
|
if (principal == null) {
|
||||||
|
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requireSuperAdmin(principal: MyPrincipal?) {
|
||||||
|
if (principal == null) {
|
||||||
|
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal")
|
||||||
|
}
|
||||||
|
val user = appUserRepo.findById(principal.userId).orElseThrow {
|
||||||
|
ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found")
|
||||||
|
}
|
||||||
|
if (!user.superAdmin) {
|
||||||
|
throw ResponseStatusException(HttpStatus.FORBIDDEN, "Super admin only")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun RoomImageTag.toResponse(): RoomImageTagResponse {
|
||||||
|
val id = id ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Tag id missing")
|
||||||
|
return RoomImageTagResponse(
|
||||||
|
id = id,
|
||||||
|
name = name
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,9 +4,12 @@ import com.android.trisolarisserver.component.PropertyAccess
|
|||||||
import com.android.trisolarisserver.component.RoomImageStorage
|
import com.android.trisolarisserver.component.RoomImageStorage
|
||||||
import com.android.trisolarisserver.controller.dto.RoomImageResponse
|
import com.android.trisolarisserver.controller.dto.RoomImageResponse
|
||||||
import com.android.trisolarisserver.controller.dto.RoomImageReorderRequest
|
import com.android.trisolarisserver.controller.dto.RoomImageReorderRequest
|
||||||
|
import com.android.trisolarisserver.controller.dto.RoomImageTagUpdateRequest
|
||||||
import com.android.trisolarisserver.models.room.RoomImage
|
import com.android.trisolarisserver.models.room.RoomImage
|
||||||
|
import com.android.trisolarisserver.models.room.RoomImageTag
|
||||||
import com.android.trisolarisserver.models.property.Role
|
import com.android.trisolarisserver.models.property.Role
|
||||||
import com.android.trisolarisserver.repo.RoomImageRepo
|
import com.android.trisolarisserver.repo.RoomImageRepo
|
||||||
|
import com.android.trisolarisserver.repo.RoomImageTagRepo
|
||||||
import com.android.trisolarisserver.repo.RoomRepo
|
import com.android.trisolarisserver.repo.RoomRepo
|
||||||
import com.android.trisolarisserver.security.MyPrincipal
|
import com.android.trisolarisserver.security.MyPrincipal
|
||||||
import org.springframework.core.io.FileSystemResource
|
import org.springframework.core.io.FileSystemResource
|
||||||
@@ -39,6 +42,7 @@ class RoomImages(
|
|||||||
private val propertyAccess: PropertyAccess,
|
private val propertyAccess: PropertyAccess,
|
||||||
private val roomRepo: RoomRepo,
|
private val roomRepo: RoomRepo,
|
||||||
private val roomImageRepo: RoomImageRepo,
|
private val roomImageRepo: RoomImageRepo,
|
||||||
|
private val roomImageTagRepo: RoomImageTagRepo,
|
||||||
private val storage: RoomImageStorage,
|
private val storage: RoomImageStorage,
|
||||||
@org.springframework.beans.factory.annotation.Value("\${storage.rooms.publicBaseUrl}")
|
@org.springframework.beans.factory.annotation.Value("\${storage.rooms.publicBaseUrl}")
|
||||||
private val publicBaseUrl: String
|
private val publicBaseUrl: String
|
||||||
@@ -64,7 +68,7 @@ class RoomImages(
|
|||||||
@PathVariable roomId: UUID,
|
@PathVariable roomId: UUID,
|
||||||
@AuthenticationPrincipal principal: MyPrincipal?,
|
@AuthenticationPrincipal principal: MyPrincipal?,
|
||||||
@RequestParam("file") file: MultipartFile,
|
@RequestParam("file") file: MultipartFile,
|
||||||
@RequestParam(required = false) tags: List<String>?
|
@RequestParam(required = false) tagIds: List<UUID>?
|
||||||
): RoomImageResponse {
|
): RoomImageResponse {
|
||||||
requirePrincipal(principal)
|
requirePrincipal(principal)
|
||||||
propertyAccess.requireMember(propertyId, principal!!.userId)
|
propertyAccess.requireMember(propertyId, principal!!.userId)
|
||||||
@@ -86,6 +90,7 @@ class RoomImages(
|
|||||||
|
|
||||||
val nextRoomSortOrder = roomImageRepo.findMaxRoomSortOrder(roomId) + 1
|
val nextRoomSortOrder = roomImageRepo.findMaxRoomSortOrder(roomId) + 1
|
||||||
val nextRoomTypeSortOrder = roomImageRepo.findMaxRoomTypeSortOrder(room.roomType.code) + 1
|
val nextRoomTypeSortOrder = roomImageRepo.findMaxRoomTypeSortOrder(room.roomType.code) + 1
|
||||||
|
val tags = resolveTags(tagIds)
|
||||||
val image = RoomImage(
|
val image = RoomImage(
|
||||||
property = room.property,
|
property = room.property,
|
||||||
room = room,
|
room = room,
|
||||||
@@ -95,7 +100,7 @@ class RoomImages(
|
|||||||
sizeBytes = stored.sizeBytes,
|
sizeBytes = stored.sizeBytes,
|
||||||
contentHash = contentHash,
|
contentHash = contentHash,
|
||||||
roomTypeCode = room.roomType.code,
|
roomTypeCode = room.roomType.code,
|
||||||
tags = tags?.toMutableSet() ?: mutableSetOf(),
|
tags = tags.toMutableSet(),
|
||||||
roomSortOrder = nextRoomSortOrder,
|
roomSortOrder = nextRoomSortOrder,
|
||||||
roomTypeSortOrder = nextRoomTypeSortOrder
|
roomTypeSortOrder = nextRoomTypeSortOrder
|
||||||
)
|
)
|
||||||
@@ -156,6 +161,28 @@ class RoomImages(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{imageId}/tags")
|
||||||
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
|
@Transactional
|
||||||
|
fun updateTags(
|
||||||
|
@PathVariable propertyId: UUID,
|
||||||
|
@PathVariable roomId: UUID,
|
||||||
|
@PathVariable imageId: UUID,
|
||||||
|
@AuthenticationPrincipal principal: MyPrincipal?,
|
||||||
|
@RequestBody request: RoomImageTagUpdateRequest
|
||||||
|
) {
|
||||||
|
requirePrincipal(principal)
|
||||||
|
propertyAccess.requireMember(propertyId, principal!!.userId)
|
||||||
|
propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN, Role.MANAGER)
|
||||||
|
ensureRoom(propertyId, roomId)
|
||||||
|
|
||||||
|
val image = roomImageRepo.findByIdAndRoomIdAndPropertyId(imageId, roomId, propertyId)
|
||||||
|
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Image not found")
|
||||||
|
val tags = resolveTags(request.tagIds.toList())
|
||||||
|
image.tags = tags.toMutableSet()
|
||||||
|
roomImageRepo.save(image)
|
||||||
|
}
|
||||||
|
|
||||||
@PutMapping("/reorder-room")
|
@PutMapping("/reorder-room")
|
||||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -262,6 +289,17 @@ class RoomImages(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun resolveTags(tagIds: List<UUID>?): Set<RoomImageTag> {
|
||||||
|
if (tagIds.isNullOrEmpty()) {
|
||||||
|
return emptySet()
|
||||||
|
}
|
||||||
|
val tags = roomImageTagRepo.findByIdIn(tagIds.toSet())
|
||||||
|
if (tags.size != tagIds.size) {
|
||||||
|
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Tag not found")
|
||||||
|
}
|
||||||
|
return tags.toSet()
|
||||||
|
}
|
||||||
|
|
||||||
private fun sha256Hex(bytes: ByteArray): String {
|
private fun sha256Hex(bytes: ByteArray): String {
|
||||||
val md = MessageDigest.getInstance("SHA-256")
|
val md = MessageDigest.getInstance("SHA-256")
|
||||||
val hash = md.digest(bytes)
|
val hash = md.digest(bytes)
|
||||||
@@ -284,9 +322,17 @@ private fun RoomImage.toResponse(baseUrl: String): RoomImageResponse {
|
|||||||
thumbnailUrl = "$baseUrl/properties/${property.id}/rooms/${room.id}/images/$id/file?size=thumb",
|
thumbnailUrl = "$baseUrl/properties/${property.id}/rooms/${room.id}/images/$id/file?size=thumb",
|
||||||
contentType = contentType,
|
contentType = contentType,
|
||||||
sizeBytes = sizeBytes,
|
sizeBytes = sizeBytes,
|
||||||
tags = tags.toSet(),
|
tags = tags.map { it.toResponse() }.toSet(),
|
||||||
roomSortOrder = roomSortOrder,
|
roomSortOrder = roomSortOrder,
|
||||||
roomTypeSortOrder = roomTypeSortOrder,
|
roomTypeSortOrder = roomTypeSortOrder,
|
||||||
createdAt = createdAt.toString()
|
createdAt = createdAt.toString()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun RoomImageTag.toResponse(): com.android.trisolarisserver.controller.dto.RoomImageTagResponse {
|
||||||
|
val id = id ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Tag id missing")
|
||||||
|
return com.android.trisolarisserver.controller.dto.RoomImageTagResponse(
|
||||||
|
id = id,
|
||||||
|
name = name
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ data class RoomImageResponse(
|
|||||||
val thumbnailUrl: String,
|
val thumbnailUrl: String,
|
||||||
val contentType: String,
|
val contentType: String,
|
||||||
val sizeBytes: Long,
|
val sizeBytes: Long,
|
||||||
val tags: Set<String>,
|
val tags: Set<RoomImageTagResponse>,
|
||||||
val roomSortOrder: Int,
|
val roomSortOrder: Int,
|
||||||
val roomTypeSortOrder: Int,
|
val roomTypeSortOrder: Int,
|
||||||
val createdAt: String
|
val createdAt: String
|
||||||
@@ -65,3 +65,16 @@ data class RoomUpsertRequest(
|
|||||||
data class RoomImageReorderRequest(
|
data class RoomImageReorderRequest(
|
||||||
val imageIds: List<UUID>
|
val imageIds: List<UUID>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class RoomImageTagUpsertRequest(
|
||||||
|
val name: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class RoomImageTagResponse(
|
||||||
|
val id: UUID,
|
||||||
|
val name: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class RoomImageTagUpdateRequest(
|
||||||
|
val tagIds: Set<UUID>
|
||||||
|
)
|
||||||
|
|||||||
@@ -39,13 +39,13 @@ class RoomImage(
|
|||||||
@Column(name = "room_type_code")
|
@Column(name = "room_type_code")
|
||||||
var roomTypeCode: String? = null,
|
var roomTypeCode: String? = null,
|
||||||
|
|
||||||
@ElementCollection(fetch = FetchType.EAGER)
|
@ManyToMany(fetch = FetchType.LAZY)
|
||||||
@CollectionTable(
|
@JoinTable(
|
||||||
name = "room_image_tag",
|
name = "room_image_tag_link",
|
||||||
joinColumns = [JoinColumn(name = "room_image_id")]
|
joinColumns = [JoinColumn(name = "room_image_id")],
|
||||||
|
inverseJoinColumns = [JoinColumn(name = "tag_id")]
|
||||||
)
|
)
|
||||||
@Column(name = "tag", nullable = false)
|
var tags: MutableSet<RoomImageTag> = mutableSetOf(),
|
||||||
var tags: MutableSet<String> = mutableSetOf(),
|
|
||||||
|
|
||||||
@Column(name = "sort_order")
|
@Column(name = "sort_order")
|
||||||
var roomSortOrder: Int = 0,
|
var roomSortOrder: Int = 0,
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package com.android.trisolarisserver.models.room
|
||||||
|
|
||||||
|
import jakarta.persistence.Column
|
||||||
|
import jakarta.persistence.Entity
|
||||||
|
import jakarta.persistence.GeneratedValue
|
||||||
|
import jakarta.persistence.Id
|
||||||
|
import jakarta.persistence.Table
|
||||||
|
import jakarta.persistence.UniqueConstraint
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(
|
||||||
|
name = "room_image_tag",
|
||||||
|
uniqueConstraints = [UniqueConstraint(columnNames = ["name"])]
|
||||||
|
)
|
||||||
|
class RoomImageTag(
|
||||||
|
@Id
|
||||||
|
@GeneratedValue
|
||||||
|
@Column(columnDefinition = "uuid")
|
||||||
|
val id: UUID? = null,
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
var name: String,
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false, columnDefinition = "timestamptz")
|
||||||
|
val createdAt: OffsetDateTime = OffsetDateTime.now()
|
||||||
|
)
|
||||||
@@ -39,6 +39,7 @@ interface RoomImageRepo : JpaRepository<RoomImage, UUID> {
|
|||||||
fun findByIdAndRoomIdAndPropertyId(id: UUID, roomId: UUID, propertyId: UUID): RoomImage?
|
fun findByIdAndRoomIdAndPropertyId(id: UUID, roomId: UUID, propertyId: UUID): RoomImage?
|
||||||
fun findByIdIn(ids: Collection<UUID>): List<RoomImage>
|
fun findByIdIn(ids: Collection<UUID>): List<RoomImage>
|
||||||
fun existsByRoomIdAndContentHash(roomId: UUID, contentHash: String): Boolean
|
fun existsByRoomIdAndContentHash(roomId: UUID, contentHash: String): Boolean
|
||||||
|
fun existsByTagsId(id: UUID): Boolean
|
||||||
|
|
||||||
@Query("select coalesce(max(ri.roomSortOrder), 0) from RoomImage ri where ri.room.id = :roomId")
|
@Query("select coalesce(max(ri.roomSortOrder), 0) from RoomImage ri where ri.room.id = :roomId")
|
||||||
fun findMaxRoomSortOrder(@Param("roomId") roomId: UUID): Int
|
fun findMaxRoomSortOrder(@Param("roomId") roomId: UUID): Int
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.android.trisolarisserver.repo
|
||||||
|
|
||||||
|
import com.android.trisolarisserver.models.room.RoomImageTag
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
interface RoomImageTagRepo : JpaRepository<RoomImageTag, UUID> {
|
||||||
|
fun findAllByOrderByName(): List<RoomImageTag>
|
||||||
|
fun findByIdIn(ids: Set<UUID>): List<RoomImageTag>
|
||||||
|
fun existsByName(name: String): Boolean
|
||||||
|
fun existsByNameAndIdNot(name: String, id: UUID): Boolean
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user