add pet and product images

This commit is contained in:
2026-03-26 19:56:17 -06:00
parent 5477c4beee
commit ba27642807
14 changed files with 823 additions and 19 deletions

View File

@@ -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 @@
]
}
]
}
}

View File

@@ -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<Resource> 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<PetResponse> deletePetImage(@PathVariable Long id) {
return ResponseEntity.ok(petService.deletePetImage(id));
}
private ResponseEntity<Map<String, String>> badRequest(String message) {
Map<String, String> 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;
}
}

View File

@@ -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<Resource> 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<ProductResponse> deleteProductImage(@PathVariable Long id) {
return ResponseEntity.ok(productService.deleteProductImage(id));
}
private ResponseEntity<Map<String, String>> badRequest(String message) {
Map<String, String> error = new HashMap<>();
error.put("message", message);
return ResponseEntity.badRequest().body(error);
}
}

View File

@@ -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 +
'}';

View File

@@ -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 +
'}';

View File

@@ -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 +
'}';

View File

@@ -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 +
'}';

View File

@@ -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<Adoption, Long> {
@@ -24,4 +26,6 @@ public interface AdoptionRepository extends JpaRepository<Adoption, Long> {
"LOWER(a.customer.lastName) LIKE LOWER(CONCAT('%', :q, '%')) OR " +
"LOWER(a.pet.petName) LIKE LOWER(CONCAT('%', :q, '%')))")
Page<Adoption> searchAdoptionsByCustomer(@Param("customerId") Long customerId, @Param("q") String query, Pageable pageable);
Optional<Adoption> findFirstByPet_IdAndAdoptionStatusOrderByAdoptionDateDesc(Long petId, String adoptionStatus);
}

View File

@@ -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";
};
}
}

View File

@@ -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<PetResponse> 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 {
}
}

View File

@@ -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<ProductResponse> 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) {
}
}

View File

@@ -0,0 +1,5 @@
ALTER TABLE pet
ADD COLUMN imageUrl VARCHAR(255) NULL;
ALTER TABLE product
ADD COLUMN imageUrl VARCHAR(255) NULL;

View File

@@ -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;
}

View File

@@ -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;
}
}