From eaee838ca3a0d106563213902c0805fd6f12b551 Mon Sep 17 00:00:00 2001 From: androidlover5842 Date: Tue, 27 Jan 2026 18:50:51 +0530 Subject: [PATCH] Add global image tags and tag assignment --- .../controller/RoomImageTags.kt | 110 ++++++++++++++++++ .../trisolarisserver/controller/RoomImages.kt | 52 ++++++++- .../controller/dto/RoomDtos.kt | 15 ++- .../trisolarisserver/models/room/RoomImage.kt | 12 +- .../models/room/RoomImageTag.kt | 28 +++++ .../trisolarisserver/repo/RoomImageRepo.kt | 1 + .../trisolarisserver/repo/RoomImageTagRepo.kt | 12 ++ 7 files changed, 220 insertions(+), 10 deletions(-) create mode 100644 src/main/kotlin/com/android/trisolarisserver/controller/RoomImageTags.kt create mode 100644 src/main/kotlin/com/android/trisolarisserver/models/room/RoomImageTag.kt create mode 100644 src/main/kotlin/com/android/trisolarisserver/repo/RoomImageTagRepo.kt diff --git a/src/main/kotlin/com/android/trisolarisserver/controller/RoomImageTags.kt b/src/main/kotlin/com/android/trisolarisserver/controller/RoomImageTags.kt new file mode 100644 index 0000000..5cfde8b --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/controller/RoomImageTags.kt @@ -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 { + 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 + ) +} diff --git a/src/main/kotlin/com/android/trisolarisserver/controller/RoomImages.kt b/src/main/kotlin/com/android/trisolarisserver/controller/RoomImages.kt index 44d183d..80afe90 100644 --- a/src/main/kotlin/com/android/trisolarisserver/controller/RoomImages.kt +++ b/src/main/kotlin/com/android/trisolarisserver/controller/RoomImages.kt @@ -4,9 +4,12 @@ import com.android.trisolarisserver.component.PropertyAccess import com.android.trisolarisserver.component.RoomImageStorage import com.android.trisolarisserver.controller.dto.RoomImageResponse 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.RoomImageTag import com.android.trisolarisserver.models.property.Role import com.android.trisolarisserver.repo.RoomImageRepo +import com.android.trisolarisserver.repo.RoomImageTagRepo import com.android.trisolarisserver.repo.RoomRepo import com.android.trisolarisserver.security.MyPrincipal import org.springframework.core.io.FileSystemResource @@ -39,6 +42,7 @@ class RoomImages( private val propertyAccess: PropertyAccess, private val roomRepo: RoomRepo, private val roomImageRepo: RoomImageRepo, + private val roomImageTagRepo: RoomImageTagRepo, private val storage: RoomImageStorage, @org.springframework.beans.factory.annotation.Value("\${storage.rooms.publicBaseUrl}") private val publicBaseUrl: String @@ -64,7 +68,7 @@ class RoomImages( @PathVariable roomId: UUID, @AuthenticationPrincipal principal: MyPrincipal?, @RequestParam("file") file: MultipartFile, - @RequestParam(required = false) tags: List? + @RequestParam(required = false) tagIds: List? ): RoomImageResponse { requirePrincipal(principal) propertyAccess.requireMember(propertyId, principal!!.userId) @@ -86,6 +90,7 @@ class RoomImages( val nextRoomSortOrder = roomImageRepo.findMaxRoomSortOrder(roomId) + 1 val nextRoomTypeSortOrder = roomImageRepo.findMaxRoomTypeSortOrder(room.roomType.code) + 1 + val tags = resolveTags(tagIds) val image = RoomImage( property = room.property, room = room, @@ -95,7 +100,7 @@ class RoomImages( sizeBytes = stored.sizeBytes, contentHash = contentHash, roomTypeCode = room.roomType.code, - tags = tags?.toMutableSet() ?: mutableSetOf(), + tags = tags.toMutableSet(), roomSortOrder = nextRoomSortOrder, 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") @ResponseStatus(HttpStatus.NO_CONTENT) @Transactional @@ -262,6 +289,17 @@ class RoomImages( } } + private fun resolveTags(tagIds: List?): Set { + 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 { val md = MessageDigest.getInstance("SHA-256") 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", contentType = contentType, sizeBytes = sizeBytes, - tags = tags.toSet(), + tags = tags.map { it.toResponse() }.toSet(), roomSortOrder = roomSortOrder, roomTypeSortOrder = roomTypeSortOrder, 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 + ) +} diff --git a/src/main/kotlin/com/android/trisolarisserver/controller/dto/RoomDtos.kt b/src/main/kotlin/com/android/trisolarisserver/controller/dto/RoomDtos.kt index 689a4f2..ac3e214 100644 --- a/src/main/kotlin/com/android/trisolarisserver/controller/dto/RoomDtos.kt +++ b/src/main/kotlin/com/android/trisolarisserver/controller/dto/RoomDtos.kt @@ -39,7 +39,7 @@ data class RoomImageResponse( val thumbnailUrl: String, val contentType: String, val sizeBytes: Long, - val tags: Set, + val tags: Set, val roomSortOrder: Int, val roomTypeSortOrder: Int, val createdAt: String @@ -65,3 +65,16 @@ data class RoomUpsertRequest( data class RoomImageReorderRequest( val imageIds: List ) + +data class RoomImageTagUpsertRequest( + val name: String +) + +data class RoomImageTagResponse( + val id: UUID, + val name: String +) + +data class RoomImageTagUpdateRequest( + val tagIds: Set +) diff --git a/src/main/kotlin/com/android/trisolarisserver/models/room/RoomImage.kt b/src/main/kotlin/com/android/trisolarisserver/models/room/RoomImage.kt index 7def18d..47a2af8 100644 --- a/src/main/kotlin/com/android/trisolarisserver/models/room/RoomImage.kt +++ b/src/main/kotlin/com/android/trisolarisserver/models/room/RoomImage.kt @@ -39,13 +39,13 @@ class RoomImage( @Column(name = "room_type_code") var roomTypeCode: String? = null, - @ElementCollection(fetch = FetchType.EAGER) - @CollectionTable( - name = "room_image_tag", - joinColumns = [JoinColumn(name = "room_image_id")] + @ManyToMany(fetch = FetchType.LAZY) + @JoinTable( + name = "room_image_tag_link", + joinColumns = [JoinColumn(name = "room_image_id")], + inverseJoinColumns = [JoinColumn(name = "tag_id")] ) - @Column(name = "tag", nullable = false) - var tags: MutableSet = mutableSetOf(), + var tags: MutableSet = mutableSetOf(), @Column(name = "sort_order") var roomSortOrder: Int = 0, diff --git a/src/main/kotlin/com/android/trisolarisserver/models/room/RoomImageTag.kt b/src/main/kotlin/com/android/trisolarisserver/models/room/RoomImageTag.kt new file mode 100644 index 0000000..b349f76 --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/models/room/RoomImageTag.kt @@ -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() +) diff --git a/src/main/kotlin/com/android/trisolarisserver/repo/RoomImageRepo.kt b/src/main/kotlin/com/android/trisolarisserver/repo/RoomImageRepo.kt index 6b89f45..839993b 100644 --- a/src/main/kotlin/com/android/trisolarisserver/repo/RoomImageRepo.kt +++ b/src/main/kotlin/com/android/trisolarisserver/repo/RoomImageRepo.kt @@ -39,6 +39,7 @@ interface RoomImageRepo : JpaRepository { fun findByIdAndRoomIdAndPropertyId(id: UUID, roomId: UUID, propertyId: UUID): RoomImage? fun findByIdIn(ids: Collection): List 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") fun findMaxRoomSortOrder(@Param("roomId") roomId: UUID): Int diff --git a/src/main/kotlin/com/android/trisolarisserver/repo/RoomImageTagRepo.kt b/src/main/kotlin/com/android/trisolarisserver/repo/RoomImageTagRepo.kt new file mode 100644 index 0000000..6d29009 --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/repo/RoomImageTagRepo.kt @@ -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 { + fun findAllByOrderByName(): List + fun findByIdIn(ids: Set): List + fun existsByName(name: String): Boolean + fun existsByNameAndIdNot(name: String, id: UUID): Boolean +}