From 2fb409f0d99d05a6da6085e7921efa189fe1bf9f Mon Sep 17 00:00:00 2001 From: Harkamal Randhawa Date: Thu, 26 Mar 2026 19:56:17 -0600 Subject: [PATCH] add pet and product images --- backend/petshop-api.postman_collection.json | 298 +++++++++++++++++- .../controller/PetImageController.java | 94 ++++++ .../controller/ProductImageController.java | 61 ++++ .../petshop/backend/dto/pet/PetResponse.java | 17 +- .../backend/dto/product/ProductResponse.java | 17 +- .../java/com/petshop/backend/entity/Pet.java | 15 +- .../com/petshop/backend/entity/Product.java | 15 +- .../repository/AdoptionRepository.java | 4 + .../service/CatalogImageStorageService.java | 97 ++++++ .../petshop/backend/service/PetService.java | 120 ++++++- .../backend/service/ProductService.java | 81 ++++- .../migration/V8__pet_product_image_urls.sql | 5 + .../api/dto/pet/PetResponse.java | 9 + .../api/dto/product/ProductResponse.java | 9 + 14 files changed, 823 insertions(+), 19 deletions(-) create mode 100644 backend/src/main/java/com/petshop/backend/controller/PetImageController.java create mode 100644 backend/src/main/java/com/petshop/backend/controller/ProductImageController.java create mode 100644 backend/src/main/java/com/petshop/backend/service/CatalogImageStorageService.java create mode 100644 backend/src/main/resources/db/migration/V8__pet_product_image_urls.sql diff --git a/backend/petshop-api.postman_collection.json b/backend/petshop-api.postman_collection.json index 0d59e4ae..ace413b0 100644 --- a/backend/petshop-api.postman_collection.json +++ b/backend/petshop-api.postman_collection.json @@ -121,6 +121,10 @@ { "key": "bulkInventoryId", "value": "" + }, + { + "key": "adoptedPetId", + "value": "4" } ], "item": [ @@ -731,6 +735,95 @@ } ] }, + { + "name": "Upload Pet Image", + "request": { + "method": "POST", + "url": "{{baseUrl}}/api/v1/pets/{{petId}}/image", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{staffToken}}", + "type": "text" + } + ], + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "image", + "type": "file", + "src": "{{avatarFile}}" + } + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status code is 200', function () {", + " pm.response.to.have.status(200);", + "});", + "var jsonData = pm.response.json();", + "pm.expect(jsonData.imageUrl).to.be.a('string');" + ] + } + } + ] + }, + { + "name": "Get Pet Image Public", + "request": { + "method": "GET", + "url": "{{baseUrl}}/api/v1/pets/{{petId}}/image" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status code is 200', function () {", + " pm.response.to.have.status(200);", + "});", + "pm.test('Pet image response is an image', function () {", + " pm.expect(pm.response.headers.get('Content-Type')).to.include('image/');", + "});" + ] + } + } + ] + }, + { + "name": "Delete Pet Image", + "request": { + "method": "DELETE", + "url": "{{baseUrl}}/api/v1/pets/{{petId}}/image", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{staffToken}}", + "type": "text" + } + ] + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status code is 200', function () {", + " pm.response.to.have.status(200);", + "});" + ] + } + } + ] + }, { "name": "Delete Pet", "request": { @@ -838,6 +931,120 @@ } } ] + }, + { + "name": "Upload Adopted Pet Image", + "request": { + "method": "POST", + "url": "{{baseUrl}}/api/v1/pets/{{adoptedPetId}}/image", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{staffToken}}", + "type": "text" + } + ], + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "image", + "type": "file", + "src": "{{avatarFile}}" + } + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status code is 200', function () {", + " pm.response.to.have.status(200);", + "});" + ] + } + } + ] + }, + { + "name": "Get Adopted Pet Image Public", + "request": { + "method": "GET", + "url": "{{baseUrl}}/api/v1/pets/{{adoptedPetId}}/image" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status code is 403', function () {", + " pm.response.to.have.status(403);", + "});" + ] + } + } + ] + }, + { + "name": "Get Adopted Pet Image As Staff", + "request": { + "method": "GET", + "url": "{{baseUrl}}/api/v1/pets/{{adoptedPetId}}/image", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{staffToken}}", + "type": "text" + } + ] + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status code is 200', function () {", + " pm.response.to.have.status(200);", + "});", + "pm.test('Pet image response is an image', function () {", + " pm.expect(pm.response.headers.get('Content-Type')).to.include('image/');", + "});" + ] + } + } + ] + }, + { + "name": "Delete Adopted Pet Image", + "request": { + "method": "DELETE", + "url": "{{baseUrl}}/api/v1/pets/{{adoptedPetId}}/image", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{staffToken}}", + "type": "text" + } + ] + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status code is 200', function () {", + " pm.response.to.have.status(200);", + "});" + ] + } + } + ] } ] }, @@ -1084,6 +1291,95 @@ } } ] + }, + { + "name": "Upload Product Image", + "request": { + "method": "POST", + "url": "{{baseUrl}}/api/v1/products/{{productId}}/image", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{staffToken}}", + "type": "text" + } + ], + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "image", + "type": "file", + "src": "{{avatarFile}}" + } + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status code is 200', function () {", + " pm.response.to.have.status(200);", + "});", + "var jsonData = pm.response.json();", + "pm.expect(jsonData.imageUrl).to.be.a('string');" + ] + } + } + ] + }, + { + "name": "Get Product Image", + "request": { + "method": "GET", + "url": "{{baseUrl}}/api/v1/products/{{productId}}/image" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status code is 200', function () {", + " pm.response.to.have.status(200);", + "});", + "pm.test('Product image response is an image', function () {", + " pm.expect(pm.response.headers.get('Content-Type')).to.include('image/');", + "});" + ] + } + } + ] + }, + { + "name": "Delete Product Image", + "request": { + "method": "DELETE", + "url": "{{baseUrl}}/api/v1/products/{{productId}}/image", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{adminToken}}", + "type": "text" + } + ] + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('Status code is 200', function () {", + " pm.response.to.have.status(200);", + "});" + ] + } + } + ] } ] }, @@ -4537,4 +4833,4 @@ ] } ] -} +} \ No newline at end of file diff --git a/backend/src/main/java/com/petshop/backend/controller/PetImageController.java b/backend/src/main/java/com/petshop/backend/controller/PetImageController.java new file mode 100644 index 00000000..bd9717ca --- /dev/null +++ b/backend/src/main/java/com/petshop/backend/controller/PetImageController.java @@ -0,0 +1,94 @@ +package com.petshop.backend.controller; + +import com.petshop.backend.dto.pet.PetResponse; +import com.petshop.backend.entity.User; +import com.petshop.backend.security.AppPrincipal; +import com.petshop.backend.service.PetService; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +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.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +@RestController +@RequestMapping("/api/v1/pets") +public class PetImageController { + + private final PetService petService; + + public PetImageController(PetService petService) { + this.petService = petService; + } + + @PostMapping("/{id}/image") + @PreAuthorize("hasAnyRole('STAFF', 'ADMIN')") + public ResponseEntity uploadPetImage(@PathVariable Long id, @RequestParam("image") MultipartFile image) { + try { + PetResponse response = petService.uploadPetImage(id, image); + return ResponseEntity.ok(response); + } catch (IllegalArgumentException ex) { + return badRequest(ex.getMessage()); + } catch (IOException ex) { + return badRequest("Failed to upload pet image: " + ex.getMessage()); + } + } + + @GetMapping("/{id}/image") + public ResponseEntity getPetImage(@PathVariable Long id) { + try { + PetService.ImagePayload payload = petService.loadPetImage(id, currentUserId(), currentUserRole()); + return ResponseEntity.ok().contentType(payload.mediaType()).body(payload.resource()); + } catch (PetService.ForbiddenImageAccessException ex) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + } + + @DeleteMapping("/{id}/image") + @PreAuthorize("hasAnyRole('STAFF', 'ADMIN')") + public ResponseEntity deletePetImage(@PathVariable Long id) { + return ResponseEntity.ok(petService.deletePetImage(id)); + } + + private ResponseEntity> badRequest(String message) { + Map error = new HashMap<>(); + error.put("message", message); + return ResponseEntity.badRequest().body(error); + } + + private Long currentUserId() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !authentication.isAuthenticated()) { + return null; + } + Object principal = authentication.getPrincipal(); + if (principal instanceof AppPrincipal appPrincipal) { + return appPrincipal.getUserId(); + } + return null; + } + + private User.Role currentUserRole() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !authentication.isAuthenticated()) { + return null; + } + Object principal = authentication.getPrincipal(); + if (principal instanceof AppPrincipal appPrincipal) { + return appPrincipal.getRole(); + } + return null; + } +} diff --git a/backend/src/main/java/com/petshop/backend/controller/ProductImageController.java b/backend/src/main/java/com/petshop/backend/controller/ProductImageController.java new file mode 100644 index 00000000..015847ad --- /dev/null +++ b/backend/src/main/java/com/petshop/backend/controller/ProductImageController.java @@ -0,0 +1,61 @@ +package com.petshop.backend.controller; + +import com.petshop.backend.dto.product.ProductResponse; +import com.petshop.backend.service.ProductService; +import org.springframework.core.io.Resource; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +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.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +@RestController +@RequestMapping("/api/v1/products") +public class ProductImageController { + + private final ProductService productService; + + public ProductImageController(ProductService productService) { + this.productService = productService; + } + + @PostMapping("/{id}/image") + @PreAuthorize("hasAnyRole('STAFF', 'ADMIN')") + public ResponseEntity uploadProductImage(@PathVariable Long id, @RequestParam("image") MultipartFile image) { + try { + ProductResponse response = productService.uploadProductImage(id, image); + return ResponseEntity.ok(response); + } catch (IllegalArgumentException ex) { + return badRequest(ex.getMessage()); + } catch (IOException ex) { + return badRequest("Failed to upload product image: " + ex.getMessage()); + } + } + + @GetMapping("/{id}/image") + public ResponseEntity getProductImage(@PathVariable Long id) { + ProductService.ImagePayload payload = productService.loadProductImage(id); + return ResponseEntity.ok().contentType(payload.mediaType()).body(payload.resource()); + } + + @DeleteMapping("/{id}/image") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity deleteProductImage(@PathVariable Long id) { + return ResponseEntity.ok(productService.deleteProductImage(id)); + } + + private ResponseEntity> badRequest(String message) { + Map error = new HashMap<>(); + error.put("message", message); + return ResponseEntity.badRequest().body(error); + } +} diff --git a/backend/src/main/java/com/petshop/backend/dto/pet/PetResponse.java b/backend/src/main/java/com/petshop/backend/dto/pet/PetResponse.java index f691361a..e3213653 100644 --- a/backend/src/main/java/com/petshop/backend/dto/pet/PetResponse.java +++ b/backend/src/main/java/com/petshop/backend/dto/pet/PetResponse.java @@ -12,13 +12,14 @@ public class PetResponse { private Integer petAge; private String petStatus; private BigDecimal petPrice; + private String imageUrl; private LocalDateTime createdAt; private LocalDateTime updatedAt; public PetResponse() { } - public PetResponse(Long petId, String petName, String petSpecies, String petBreed, Integer petAge, String petStatus, BigDecimal petPrice, LocalDateTime createdAt, LocalDateTime updatedAt) { + public PetResponse(Long petId, String petName, String petSpecies, String petBreed, Integer petAge, String petStatus, BigDecimal petPrice, String imageUrl, LocalDateTime createdAt, LocalDateTime updatedAt) { this.petId = petId; this.petName = petName; this.petSpecies = petSpecies; @@ -26,6 +27,7 @@ public class PetResponse { this.petAge = petAge; this.petStatus = petStatus; this.petPrice = petPrice; + this.imageUrl = imageUrl; this.createdAt = createdAt; this.updatedAt = updatedAt; } @@ -86,6 +88,14 @@ public class PetResponse { this.petPrice = petPrice; } + public String getImageUrl() { + return imageUrl; + } + + public void setImageUrl(String imageUrl) { + this.imageUrl = imageUrl; + } + public LocalDateTime getCreatedAt() { return createdAt; } @@ -107,12 +117,12 @@ public class PetResponse { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; PetResponse that = (PetResponse) o; - return Objects.equals(petId, that.petId) && Objects.equals(petName, that.petName) && Objects.equals(petSpecies, that.petSpecies) && Objects.equals(petBreed, that.petBreed) && Objects.equals(petAge, that.petAge) && Objects.equals(petStatus, that.petStatus) && Objects.equals(petPrice, that.petPrice) && Objects.equals(createdAt, that.createdAt) && Objects.equals(updatedAt, that.updatedAt); + return Objects.equals(petId, that.petId) && Objects.equals(petName, that.petName) && Objects.equals(petSpecies, that.petSpecies) && Objects.equals(petBreed, that.petBreed) && Objects.equals(petAge, that.petAge) && Objects.equals(petStatus, that.petStatus) && Objects.equals(petPrice, that.petPrice) && Objects.equals(imageUrl, that.imageUrl) && Objects.equals(createdAt, that.createdAt) && Objects.equals(updatedAt, that.updatedAt); } @Override public int hashCode() { - return Objects.hash(petId, petName, petSpecies, petBreed, petAge, petStatus, petPrice, createdAt, updatedAt); + return Objects.hash(petId, petName, petSpecies, petBreed, petAge, petStatus, petPrice, imageUrl, createdAt, updatedAt); } @Override @@ -125,6 +135,7 @@ public class PetResponse { ", petAge=" + petAge + ", petStatus='" + petStatus + '\'' + ", petPrice=" + petPrice + + ", imageUrl='" + imageUrl + '\'' + ", createdAt=" + createdAt + ", updatedAt=" + updatedAt + '}'; diff --git a/backend/src/main/java/com/petshop/backend/dto/product/ProductResponse.java b/backend/src/main/java/com/petshop/backend/dto/product/ProductResponse.java index 96baa5ce..c08abf9a 100644 --- a/backend/src/main/java/com/petshop/backend/dto/product/ProductResponse.java +++ b/backend/src/main/java/com/petshop/backend/dto/product/ProductResponse.java @@ -11,19 +11,21 @@ public class ProductResponse { private String categoryName; private String prodDesc; private BigDecimal prodPrice; + private String imageUrl; private LocalDateTime createdAt; private LocalDateTime updatedAt; public ProductResponse() { } - public ProductResponse(Long prodId, String prodName, Long categoryId, String categoryName, String prodDesc, BigDecimal prodPrice, LocalDateTime createdAt, LocalDateTime updatedAt) { + public ProductResponse(Long prodId, String prodName, Long categoryId, String categoryName, String prodDesc, BigDecimal prodPrice, String imageUrl, LocalDateTime createdAt, LocalDateTime updatedAt) { this.prodId = prodId; this.prodName = prodName; this.categoryId = categoryId; this.categoryName = categoryName; this.prodDesc = prodDesc; this.prodPrice = prodPrice; + this.imageUrl = imageUrl; this.createdAt = createdAt; this.updatedAt = updatedAt; } @@ -76,6 +78,14 @@ public class ProductResponse { this.prodPrice = prodPrice; } + public String getImageUrl() { + return imageUrl; + } + + public void setImageUrl(String imageUrl) { + this.imageUrl = imageUrl; + } + public LocalDateTime getCreatedAt() { return createdAt; } @@ -97,12 +107,12 @@ public class ProductResponse { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; ProductResponse that = (ProductResponse) o; - return Objects.equals(prodId, that.prodId) && Objects.equals(prodName, that.prodName) && Objects.equals(categoryId, that.categoryId) && Objects.equals(categoryName, that.categoryName) && Objects.equals(prodDesc, that.prodDesc) && Objects.equals(prodPrice, that.prodPrice) && Objects.equals(createdAt, that.createdAt) && Objects.equals(updatedAt, that.updatedAt); + return Objects.equals(prodId, that.prodId) && Objects.equals(prodName, that.prodName) && Objects.equals(categoryId, that.categoryId) && Objects.equals(categoryName, that.categoryName) && Objects.equals(prodDesc, that.prodDesc) && Objects.equals(prodPrice, that.prodPrice) && Objects.equals(imageUrl, that.imageUrl) && Objects.equals(createdAt, that.createdAt) && Objects.equals(updatedAt, that.updatedAt); } @Override public int hashCode() { - return Objects.hash(prodId, prodName, categoryId, categoryName, prodDesc, prodPrice, createdAt, updatedAt); + return Objects.hash(prodId, prodName, categoryId, categoryName, prodDesc, prodPrice, imageUrl, createdAt, updatedAt); } @Override @@ -114,6 +124,7 @@ public class ProductResponse { ", categoryName='" + categoryName + '\'' + ", prodDesc='" + prodDesc + '\'' + ", prodPrice=" + prodPrice + + ", imageUrl='" + imageUrl + '\'' + ", createdAt=" + createdAt + ", updatedAt=" + updatedAt + '}'; diff --git a/backend/src/main/java/com/petshop/backend/entity/Pet.java b/backend/src/main/java/com/petshop/backend/entity/Pet.java index e827f612..8f6a6020 100644 --- a/backend/src/main/java/com/petshop/backend/entity/Pet.java +++ b/backend/src/main/java/com/petshop/backend/entity/Pet.java @@ -35,6 +35,9 @@ public class Pet { @Column(nullable = false, precision = 10, scale = 2) private BigDecimal petPrice; + @Column(length = 255) + private String imageUrl; + @CreationTimestamp @Column(name = "created_at", updatable = false) private LocalDateTime createdAt; @@ -46,7 +49,7 @@ public class Pet { public Pet() { } - public Pet(Long id, String petName, String petSpecies, String petBreed, Integer petAge, String petStatus, BigDecimal petPrice, LocalDateTime createdAt, LocalDateTime updatedAt) { + public Pet(Long id, String petName, String petSpecies, String petBreed, Integer petAge, String petStatus, BigDecimal petPrice, String imageUrl, LocalDateTime createdAt, LocalDateTime updatedAt) { this.id = id; this.petName = petName; this.petSpecies = petSpecies; @@ -54,6 +57,7 @@ public class Pet { this.petAge = petAge; this.petStatus = petStatus; this.petPrice = petPrice; + this.imageUrl = imageUrl; this.createdAt = createdAt; this.updatedAt = updatedAt; } @@ -114,6 +118,14 @@ public class Pet { this.petPrice = petPrice; } + public String getImageUrl() { + return imageUrl; + } + + public void setImageUrl(String imageUrl) { + this.imageUrl = imageUrl; + } + public LocalDateTime getCreatedAt() { return createdAt; } @@ -153,6 +165,7 @@ public class Pet { ", petAge=" + petAge + ", petStatus='" + petStatus + '\'' + ", petPrice=" + petPrice + + ", imageUrl='" + imageUrl + '\'' + ", createdAt=" + createdAt + ", updatedAt=" + updatedAt + '}'; diff --git a/backend/src/main/java/com/petshop/backend/entity/Product.java b/backend/src/main/java/com/petshop/backend/entity/Product.java index 9eb9c2d6..84c17c1a 100644 --- a/backend/src/main/java/com/petshop/backend/entity/Product.java +++ b/backend/src/main/java/com/petshop/backend/entity/Product.java @@ -29,6 +29,9 @@ public class Product { @Column(nullable = false, precision = 10, scale = 2) private BigDecimal prodPrice; + @Column(length = 255) + private String imageUrl; + @CreationTimestamp @Column(name = "created_at", updatable = false) private LocalDateTime createdAt; @@ -40,12 +43,13 @@ public class Product { public Product() { } - public Product(Long prodId, String prodName, Category category, String prodDesc, BigDecimal prodPrice, LocalDateTime createdAt, LocalDateTime updatedAt) { + public Product(Long prodId, String prodName, Category category, String prodDesc, BigDecimal prodPrice, String imageUrl, LocalDateTime createdAt, LocalDateTime updatedAt) { this.prodId = prodId; this.prodName = prodName; this.category = category; this.prodDesc = prodDesc; this.prodPrice = prodPrice; + this.imageUrl = imageUrl; this.createdAt = createdAt; this.updatedAt = updatedAt; } @@ -90,6 +94,14 @@ public class Product { this.prodPrice = prodPrice; } + public String getImageUrl() { + return imageUrl; + } + + public void setImageUrl(String imageUrl) { + this.imageUrl = imageUrl; + } + public LocalDateTime getCreatedAt() { return createdAt; } @@ -127,6 +139,7 @@ public class Product { ", category=" + category + ", prodDesc='" + prodDesc + '\'' + ", prodPrice=" + prodPrice + + ", imageUrl='" + imageUrl + '\'' + ", createdAt=" + createdAt + ", updatedAt=" + updatedAt + '}'; diff --git a/backend/src/main/java/com/petshop/backend/repository/AdoptionRepository.java b/backend/src/main/java/com/petshop/backend/repository/AdoptionRepository.java index d009b17a..2af9c52f 100644 --- a/backend/src/main/java/com/petshop/backend/repository/AdoptionRepository.java +++ b/backend/src/main/java/com/petshop/backend/repository/AdoptionRepository.java @@ -8,6 +8,8 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import java.util.Optional; + @Repository public interface AdoptionRepository extends JpaRepository { @@ -24,4 +26,6 @@ public interface AdoptionRepository extends JpaRepository { "LOWER(a.customer.lastName) LIKE LOWER(CONCAT('%', :q, '%')) OR " + "LOWER(a.pet.petName) LIKE LOWER(CONCAT('%', :q, '%')))") Page searchAdoptionsByCustomer(@Param("customerId") Long customerId, @Param("q") String query, Pageable pageable); + + Optional findFirstByPet_IdAndAdoptionStatusOrderByAdoptionDateDesc(Long petId, String adoptionStatus); } diff --git a/backend/src/main/java/com/petshop/backend/service/CatalogImageStorageService.java b/backend/src/main/java/com/petshop/backend/service/CatalogImageStorageService.java new file mode 100644 index 00000000..34a92ff0 --- /dev/null +++ b/backend/src/main/java/com/petshop/backend/service/CatalogImageStorageService.java @@ -0,0 +1,97 @@ +package com.petshop.backend.service; + +import org.springframework.core.io.PathResource; +import org.springframework.core.io.Resource; +import org.springframework.http.MediaType; +import org.springframework.http.MediaTypeFactory; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.util.Locale; +import java.util.UUID; + +@Service +public class CatalogImageStorageService { + + private static final String PET_PREFIX = "/uploads/pets/"; + private static final String PRODUCT_PREFIX = "/uploads/products/"; + + public String storePetImage(MultipartFile file) throws IOException { + return storeImage(file, Paths.get("uploads", "pets").toAbsolutePath().normalize(), PET_PREFIX); + } + + public String storeProductImage(MultipartFile file) throws IOException { + return storeImage(file, Paths.get("uploads", "products").toAbsolutePath().normalize(), PRODUCT_PREFIX); + } + + public Resource loadPetImage(String storedPath) { + return new PathResource(resolveStoredPath(storedPath, Paths.get("uploads", "pets").toAbsolutePath().normalize(), PET_PREFIX)); + } + + public Resource loadProductImage(String storedPath) { + return new PathResource(resolveStoredPath(storedPath, Paths.get("uploads", "products").toAbsolutePath().normalize(), PRODUCT_PREFIX)); + } + + public MediaType resolveMediaType(Resource resource) { + return MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM); + } + + public void deletePetImage(String storedPath) throws IOException { + deleteImage(storedPath, Paths.get("uploads", "pets").toAbsolutePath().normalize(), PET_PREFIX); + } + + public void deleteProductImage(String storedPath) throws IOException { + deleteImage(storedPath, Paths.get("uploads", "products").toAbsolutePath().normalize(), PRODUCT_PREFIX); + } + + private String storeImage(MultipartFile file, Path directory, String prefix) throws IOException { + Files.createDirectories(directory); + String extension = resolveExtension(file.getOriginalFilename()); + String filename = UUID.randomUUID() + extension; + Path filePath = directory.resolve(filename).normalize(); + Files.copy(file.getInputStream(), filePath, StandardCopyOption.REPLACE_EXISTING); + return prefix + filename; + } + + private void deleteImage(String storedPath, Path directory, String prefix) throws IOException { + if (storedPath == null || storedPath.isBlank()) { + return; + } + Files.deleteIfExists(resolveStoredPath(storedPath, directory, prefix)); + } + + private Path resolveStoredPath(String storedPath, Path directory, String prefix) { + if (storedPath == null || storedPath.isBlank() || !storedPath.startsWith(prefix)) { + throw new IllegalArgumentException("Image file was not found"); + } + String filename = storedPath.substring(prefix.length()); + if (filename.isBlank() || filename.contains("/") || filename.contains("\\") || filename.contains("..")) { + throw new IllegalArgumentException("Image file was not found"); + } + Path resolved = directory.resolve(filename).normalize(); + if (!resolved.startsWith(directory)) { + throw new IllegalArgumentException("Image file was not found"); + } + return resolved; + } + + private String resolveExtension(String originalFilename) { + if (originalFilename == null) { + return ".jpg"; + } + int extensionIndex = originalFilename.lastIndexOf('.'); + if (extensionIndex < 0 || extensionIndex == originalFilename.length() - 1) { + return ".jpg"; + } + String extension = originalFilename.substring(extensionIndex).toLowerCase(Locale.ROOT); + return switch (extension) { + case ".jpg", ".jpeg", ".png", ".gif" -> extension; + default -> ".jpg"; + }; + } +} diff --git a/backend/src/main/java/com/petshop/backend/service/PetService.java b/backend/src/main/java/com/petshop/backend/service/PetService.java index b59d589b..5c35dfd1 100644 --- a/backend/src/main/java/com/petshop/backend/service/PetService.java +++ b/backend/src/main/java/com/petshop/backend/service/PetService.java @@ -3,21 +3,34 @@ package com.petshop.backend.service; import com.petshop.backend.dto.common.BulkDeleteRequest; import com.petshop.backend.dto.pet.PetRequest; import com.petshop.backend.dto.pet.PetResponse; +import com.petshop.backend.entity.Adoption; import com.petshop.backend.entity.Pet; +import com.petshop.backend.entity.User; import com.petshop.backend.exception.ResourceNotFoundException; +import com.petshop.backend.repository.AdoptionRepository; import com.petshop.backend.repository.PetRepository; +import org.springframework.core.io.Resource; +import org.springframework.http.MediaType; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.Locale; @Service public class PetService { private final PetRepository petRepository; + private final AdoptionRepository adoptionRepository; + private final CatalogImageStorageService catalogImageStorageService; - public PetService(PetRepository petRepository) { + public PetService(PetRepository petRepository, AdoptionRepository adoptionRepository, CatalogImageStorageService catalogImageStorageService) { this.petRepository = petRepository; + this.adoptionRepository = adoptionRepository; + this.catalogImageStorageService = catalogImageStorageService; } public Page getAllPets(String query, Pageable pageable) { @@ -68,17 +81,107 @@ public class PetService { @Transactional public void deletePet(Long id) { - if (!petRepository.existsById(id)) { - throw new ResourceNotFoundException("Pet not found with id: " + id); - } - petRepository.deleteById(id); + Pet pet = petRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("Pet not found with id: " + id)); + deleteStoredImageIfPresent(pet.getImageUrl()); + petRepository.delete(pet); } @Transactional public void bulkDeletePets(BulkDeleteRequest request) { + petRepository.findAllById(request.getIds()).forEach(pet -> deleteStoredImageIfPresent(pet.getImageUrl())); petRepository.deleteAllById(request.getIds()); } + @Transactional + public PetResponse uploadPetImage(Long id, MultipartFile file) throws IOException { + validateImageFile(file); + Pet pet = findPet(id); + deleteStoredImageIfPresent(pet.getImageUrl()); + pet.setImageUrl(catalogImageStorageService.storePetImage(file)); + return mapToResponse(petRepository.save(pet)); + } + + @Transactional + public PetResponse deletePetImage(Long id) { + Pet pet = findPet(id); + deleteStoredImageIfPresent(pet.getImageUrl()); + pet.setImageUrl(null); + return mapToResponse(petRepository.save(pet)); + } + + public ImagePayload loadPetImage(Long id, Long requesterUserId, User.Role requesterRole) { + Pet pet = findPet(id); + if (pet.getImageUrl() == null || pet.getImageUrl().isBlank()) { + throw new ResourceNotFoundException("Pet image not found for id: " + id); + } + if (!canViewPetImage(pet, requesterUserId, requesterRole)) { + throw new ForbiddenImageAccessException(); + } + Resource resource = catalogImageStorageService.loadPetImage(pet.getImageUrl()); + MediaType mediaType = catalogImageStorageService.resolveMediaType(resource); + return new ImagePayload(resource, mediaType); + } + + public boolean isPubliclyVisible(Pet pet) { + return "available".equalsIgnoreCase(normalizeStatus(pet.getPetStatus())); + } + + private boolean canViewPetImage(Pet pet, Long requesterUserId, User.Role requesterRole) { + if (isPubliclyVisible(pet)) { + return true; + } + if (requesterRole == User.Role.STAFF || requesterRole == User.Role.ADMIN) { + return true; + } + if (requesterUserId == null) { + return false; + } + if (!"adopted".equalsIgnoreCase(normalizeStatus(pet.getPetStatus()))) { + return false; + } + return adoptionRepository.findFirstByPet_IdAndAdoptionStatusOrderByAdoptionDateDesc(pet.getPetId(), "Completed") + .map(Adoption::getCustomer) + .map(customer -> requesterUserId.equals(customer.getUserId())) + .orElse(false); + } + + private Pet findPet(Long id) { + return petRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("Pet not found with id: " + id)); + } + + private void validateImageFile(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new IllegalArgumentException("Please select an image to upload"); + } + if (file.getSize() > 5 * 1024 * 1024) { + throw new IllegalArgumentException("Image file size must be less than 5MB"); + } + String contentType = file.getContentType(); + if (contentType == null) { + throw new IllegalArgumentException("Only JPG, PNG, and GIF images are allowed"); + } + String normalized = contentType.toLowerCase(Locale.ROOT); + if (!normalized.equals("image/jpeg") && !normalized.equals("image/png") && !normalized.equals("image/gif")) { + throw new IllegalArgumentException("Only JPG, PNG, and GIF images are allowed"); + } + } + + private void deleteStoredImageIfPresent(String storedImagePath) { + if (storedImagePath == null || storedImagePath.isBlank()) { + return; + } + try { + catalogImageStorageService.deletePetImage(storedImagePath); + } catch (IOException ignored) { + } + } + + private String normalizeStatus(String status) { + return status == null ? "" : status.trim(); + } + private PetResponse mapToResponse(Pet pet) { return new PetResponse( pet.getPetId(), @@ -88,8 +191,15 @@ public class PetService { pet.getPetAge(), pet.getPetStatus(), pet.getPetPrice(), + pet.getImageUrl() != null && !pet.getImageUrl().isBlank() ? "/api/v1/pets/" + pet.getPetId() + "/image" : null, pet.getCreatedAt(), pet.getUpdatedAt() ); } + + public record ImagePayload(Resource resource, MediaType mediaType) { + } + + public static class ForbiddenImageAccessException extends RuntimeException { + } } diff --git a/backend/src/main/java/com/petshop/backend/service/ProductService.java b/backend/src/main/java/com/petshop/backend/service/ProductService.java index b907e38f..0473a8eb 100644 --- a/backend/src/main/java/com/petshop/backend/service/ProductService.java +++ b/backend/src/main/java/com/petshop/backend/service/ProductService.java @@ -8,20 +8,28 @@ import com.petshop.backend.entity.Product; import com.petshop.backend.exception.ResourceNotFoundException; import com.petshop.backend.repository.CategoryRepository; import com.petshop.backend.repository.ProductRepository; +import org.springframework.core.io.Resource; +import org.springframework.http.MediaType; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.Locale; @Service public class ProductService { private final ProductRepository productRepository; private final CategoryRepository categoryRepository; + private final CatalogImageStorageService catalogImageStorageService; - public ProductService(ProductRepository productRepository, CategoryRepository categoryRepository) { + public ProductService(ProductRepository productRepository, CategoryRepository categoryRepository, CatalogImageStorageService catalogImageStorageService) { this.productRepository = productRepository; this.categoryRepository = categoryRepository; + this.catalogImageStorageService = catalogImageStorageService; } public Page getAllProducts(String query, Pageable pageable) { @@ -74,17 +82,76 @@ public class ProductService { @Transactional public void deleteProduct(Long id) { - if (!productRepository.existsById(id)) { - throw new ResourceNotFoundException("Product not found with id: " + id); - } - productRepository.deleteById(id); + Product product = findProduct(id); + deleteStoredImageIfPresent(product.getImageUrl()); + productRepository.delete(product); } @Transactional public void bulkDeleteProducts(BulkDeleteRequest request) { + productRepository.findAllById(request.getIds()).forEach(product -> deleteStoredImageIfPresent(product.getImageUrl())); productRepository.deleteAllById(request.getIds()); } + @Transactional + public ProductResponse uploadProductImage(Long id, MultipartFile file) throws IOException { + validateImageFile(file); + Product product = findProduct(id); + deleteStoredImageIfPresent(product.getImageUrl()); + product.setImageUrl(catalogImageStorageService.storeProductImage(file)); + return mapToResponse(productRepository.save(product)); + } + + @Transactional + public ProductResponse deleteProductImage(Long id) { + Product product = findProduct(id); + deleteStoredImageIfPresent(product.getImageUrl()); + product.setImageUrl(null); + return mapToResponse(productRepository.save(product)); + } + + public ImagePayload loadProductImage(Long id) { + Product product = findProduct(id); + if (product.getImageUrl() == null || product.getImageUrl().isBlank()) { + throw new ResourceNotFoundException("Product image not found with id: " + id); + } + Resource resource = catalogImageStorageService.loadProductImage(product.getImageUrl()); + MediaType mediaType = catalogImageStorageService.resolveMediaType(resource); + return new ImagePayload(resource, mediaType); + } + + private Product findProduct(Long id) { + return productRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("Product not found with id: " + id)); + } + + private void validateImageFile(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new IllegalArgumentException("Please select an image to upload"); + } + if (file.getSize() > 5 * 1024 * 1024) { + throw new IllegalArgumentException("Image file size must be less than 5MB"); + } + String contentType = file.getContentType(); + if (contentType == null) { + throw new IllegalArgumentException("Only JPG, PNG, and GIF images are allowed"); + } + String normalized = contentType.toLowerCase(Locale.ROOT); + if (!normalized.equals("image/jpeg") && !normalized.equals("image/png") && !normalized.equals("image/gif")) { + throw new IllegalArgumentException("Only JPG, PNG, and GIF images are allowed"); + } + } + + private void deleteStoredImageIfPresent(String storedImagePath) { + if (storedImagePath == null || storedImagePath.isBlank()) { + return; + } + try { + catalogImageStorageService.deleteProductImage(storedImagePath); + } catch (IOException ignored) { + } + } + private ProductResponse mapToResponse(Product product) { return new ProductResponse( product.getProdId(), @@ -93,8 +160,12 @@ public class ProductService { product.getCategory().getCategoryName(), product.getProdDesc(), product.getProdPrice(), + product.getImageUrl() != null && !product.getImageUrl().isBlank() ? "/api/v1/products/" + product.getProdId() + "/image" : null, product.getCreatedAt(), product.getUpdatedAt() ); } + + public record ImagePayload(Resource resource, MediaType mediaType) { + } } diff --git a/backend/src/main/resources/db/migration/V8__pet_product_image_urls.sql b/backend/src/main/resources/db/migration/V8__pet_product_image_urls.sql new file mode 100644 index 00000000..a4c98248 --- /dev/null +++ b/backend/src/main/resources/db/migration/V8__pet_product_image_urls.sql @@ -0,0 +1,5 @@ +ALTER TABLE pet + ADD COLUMN imageUrl VARCHAR(255) NULL; + +ALTER TABLE product + ADD COLUMN imageUrl VARCHAR(255) NULL; diff --git a/desktop/src/main/java/org/example/petshopdesktop/api/dto/pet/PetResponse.java b/desktop/src/main/java/org/example/petshopdesktop/api/dto/pet/PetResponse.java index a7932253..b1155214 100644 --- a/desktop/src/main/java/org/example/petshopdesktop/api/dto/pet/PetResponse.java +++ b/desktop/src/main/java/org/example/petshopdesktop/api/dto/pet/PetResponse.java @@ -11,6 +11,7 @@ public class PetResponse { private Integer petAge; private String petStatus; private BigDecimal petPrice; + private String imageUrl; private LocalDateTime createdAt; private LocalDateTime updatedAt; @@ -73,6 +74,14 @@ public class PetResponse { this.petPrice = petPrice; } + public String getImageUrl() { + return imageUrl; + } + + public void setImageUrl(String imageUrl) { + this.imageUrl = imageUrl; + } + public LocalDateTime getCreatedAt() { return createdAt; } diff --git a/desktop/src/main/java/org/example/petshopdesktop/api/dto/product/ProductResponse.java b/desktop/src/main/java/org/example/petshopdesktop/api/dto/product/ProductResponse.java index c989fdf5..18f9b678 100644 --- a/desktop/src/main/java/org/example/petshopdesktop/api/dto/product/ProductResponse.java +++ b/desktop/src/main/java/org/example/petshopdesktop/api/dto/product/ProductResponse.java @@ -8,6 +8,7 @@ public class ProductResponse { private String categoryName; private BigDecimal prodPrice; private String prodDesc; + private String imageUrl; public ProductResponse() { } @@ -51,4 +52,12 @@ public class ProductResponse { public void setProdDesc(String prodDesc) { this.prodDesc = prodDesc; } + + public String getImageUrl() { + return imageUrl; + } + + public void setImageUrl(String imageUrl) { + this.imageUrl = imageUrl; + } }