Merge pull request #135 from RecentRunner/clean-demo-branch

Protect appointment visibility
This commit was merged in pull request #135.
This commit is contained in:
2026-04-04 16:28:34 -06:00
committed by GitHub
12 changed files with 488 additions and 32 deletions

View File

@@ -1,10 +1,12 @@
package com.petshop.backend.controller;
import com.petshop.backend.dto.common.DropdownOption;
import com.petshop.backend.entity.CustomerPet;
import com.petshop.backend.repository.*;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@@ -17,6 +19,7 @@ public class DropdownController {
private final PetRepository petRepository;
private final CustomerRepository customerRepository;
private final CustomerPetRepository customerPetRepository;
private final ServiceRepository serviceRepository;
private final ProductRepository productRepository;
private final CategoryRepository categoryRepository;
@@ -24,11 +27,13 @@ public class DropdownController {
private final SupplierRepository supplierRepository;
public DropdownController(PetRepository petRepository, CustomerRepository customerRepository,
CustomerPetRepository customerPetRepository,
ServiceRepository serviceRepository, ProductRepository productRepository,
CategoryRepository categoryRepository, StoreRepository storeRepository,
SupplierRepository supplierRepository) {
this.petRepository = petRepository;
this.customerRepository = customerRepository;
this.customerPetRepository = customerPetRepository;
this.serviceRepository = serviceRepository;
this.productRepository = productRepository;
this.categoryRepository = categoryRepository;
@@ -55,6 +60,16 @@ public class DropdownController {
);
}
@GetMapping("/customers/{customerId}/pets")
@PreAuthorize("hasAnyRole('STAFF', 'ADMIN')")
public ResponseEntity<List<DropdownOption>> getCustomerPets(@PathVariable Long customerId) {
return ResponseEntity.ok(
customerPetRepository.findByCustomerCustomerIdOrderByPetNameAsc(customerId).stream()
.map(this::toCustomerPetOption)
.collect(Collectors.toList())
);
}
@GetMapping("/services")
public ResponseEntity<List<DropdownOption>> getServices() {
return ResponseEntity.ok(
@@ -123,4 +138,10 @@ public class DropdownController {
.collect(Collectors.toList())
);
}
private DropdownOption toCustomerPetOption(CustomerPet pet) {
String species = pet.getSpecies() == null || pet.getSpecies().isBlank() ? "Pet" : pet.getSpecies();
String breed = pet.getBreed() == null || pet.getBreed().isBlank() ? "" : " · " + pet.getBreed();
return new DropdownOption(pet.getCustomerPetId(), pet.getPetName() + " (" + species + breed + ")");
}
}

View File

@@ -12,5 +12,7 @@ public interface CustomerPetRepository extends JpaRepository<CustomerPet, Long>
List<CustomerPet> findByCustomerCustomerIdOrderByCreatedAtDesc(Long customerId);
List<CustomerPet> findByCustomerCustomerIdOrderByPetNameAsc(Long customerId);
Optional<CustomerPet> findByCustomerPetIdAndCustomerCustomerId(Long customerPetId, Long customerId);
}

View File

@@ -16,4 +16,16 @@ public interface PetRepository extends JpaRepository<Pet, Long> {
"(:species IS NULL OR LOWER(p.petSpecies) = LOWER(:species)) AND " +
"(:status IS NULL OR LOWER(p.petStatus) = LOWER(:status))")
Page<Pet> searchPets(@Param("q") String query, @Param("species") String species, @Param("status") String status, Pageable pageable);
@Query("SELECT p FROM Pet p WHERE LOWER(p.petStatus) = 'available' AND " +
"(:q IS NULL OR LOWER(p.petName) LIKE LOWER(CONCAT('%', :q, '%')) OR LOWER(p.petSpecies) LIKE LOWER(CONCAT('%', :q, '%')) OR LOWER(p.petBreed) LIKE LOWER(CONCAT('%', :q, '%'))) AND " +
"(:species IS NULL OR LOWER(p.petSpecies) = LOWER(:species))")
Page<Pet> searchPublicPets(@Param("q") String query, @Param("species") String species, Pageable pageable);
@Query("SELECT DISTINCT p FROM Pet p LEFT JOIN Adoption a ON a.pet = p AND LOWER(a.adoptionStatus) = 'completed' WHERE " +
"(LOWER(p.petStatus) = 'available' OR a.customer.userId = :userId) AND " +
"(:q IS NULL OR LOWER(p.petName) LIKE LOWER(CONCAT('%', :q, '%')) OR LOWER(p.petSpecies) LIKE LOWER(CONCAT('%', :q, '%')) OR LOWER(p.petBreed) LIKE LOWER(CONCAT('%', :q, '%'))) AND " +
"(:species IS NULL OR LOWER(p.petSpecies) = LOWER(:species)) AND " +
"(:status IS NULL OR LOWER(p.petStatus) = LOWER(:status))")
Page<Pet> searchCustomerVisiblePets(@Param("userId") Long userId, @Param("q") String query, @Param("species") String species, @Param("status") String status, Pageable pageable);
}

View File

@@ -120,7 +120,7 @@ public class AppointmentService {
}
Set<Pet> pets = hasPetIds ? fetchPets(request.getPetIds()) : new HashSet<>();
Set<CustomerPet> customerPets = hasCustomerPetIds ? fetchCustomerPets(request.getCustomerPetIds()) : new HashSet<>();
Set<CustomerPet> customerPets = hasCustomerPetIds ? fetchCustomerPets(request.getCustomerPetIds(), customer.getCustomerId()) : new HashSet<>();
Appointment appointment = new Appointment();
appointment.setCustomer(customer);
@@ -164,7 +164,7 @@ public class AppointmentService {
}
Set<Pet> pets = hasPetIds ? fetchPets(request.getPetIds()) : new HashSet<>();
Set<CustomerPet> customerPets = hasCustomerPetIds ? fetchCustomerPets(request.getCustomerPetIds()) : new HashSet<>();
Set<CustomerPet> customerPets = hasCustomerPetIds ? fetchCustomerPets(request.getCustomerPetIds(), customer.getCustomerId()) : new HashSet<>();
appointment.setCustomer(customer);
appointment.setStore(store);
@@ -247,11 +247,14 @@ public class AppointmentService {
return pets;
}
private Set<CustomerPet> fetchCustomerPets(List<Long> customerPetIds) {
private Set<CustomerPet> fetchCustomerPets(List<Long> customerPetIds, Long customerId) {
Set<CustomerPet> customerPets = new HashSet<>();
for (Long customerPetId : customerPetIds) {
CustomerPet customerPet = customerPetRepository.findById(customerPetId)
.orElseThrow(() -> new ResourceNotFoundException("Customer pet not found with id: " + customerPetId));
if (!customerPet.getCustomer().getCustomerId().equals(customerId)) {
throw new IllegalArgumentException("Selected pet does not belong to the selected customer");
}
customerPets.add(customerPet);
}

View File

@@ -7,12 +7,16 @@ 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.security.AppPrincipal;
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.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
@@ -34,13 +38,38 @@ public class PetService {
}
public Page<PetResponse> getAllPets(String query, String species, String status, Pageable pageable) {
return petRepository.searchPets(normalizeFilter(query), normalizeFilter(species), normalizeFilter(status), pageable)
String normalizedQuery = normalizeFilter(query);
String normalizedSpecies = normalizeFilter(species);
String normalizedStatus = normalizeFilter(status);
CurrentViewer viewer = getCurrentViewer();
Page<Pet> pets;
if (viewer == null) {
if (!isAllowedPublicStatus(normalizedStatus)) {
return new PageImpl<>(java.util.List.of(), pageable, 0);
}
pets = petRepository.searchPublicPets(normalizedQuery, normalizedSpecies, pageable);
} else if (viewer.role() == User.Role.STAFF || viewer.role() == User.Role.ADMIN) {
pets = petRepository.searchPets(normalizedQuery, normalizedSpecies, normalizedStatus, pageable);
} else if (viewer.role() == User.Role.CUSTOMER) {
if (!isAllowedCustomerStatus(normalizedStatus)) {
return new PageImpl<>(java.util.List.of(), pageable, 0);
}
pets = petRepository.searchCustomerVisiblePets(viewer.userId(), normalizedQuery, normalizedSpecies, normalizedStatus, pageable);
} else {
pets = petRepository.searchPublicPets(normalizedQuery, normalizedSpecies, pageable);
}
return pets
.map(this::mapToResponse);
}
public PetResponse getPetById(Long id) {
Pet pet = petRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Pet not found with id: " + id));
if (!canViewPet(pet, getCurrentViewer())) {
throw new ResourceNotFoundException("Pet not found with id: " + id);
}
return mapToResponse(pet);
}
@@ -110,7 +139,7 @@ public class PetService {
if (pet.getImageUrl() == null || pet.getImageUrl().isBlank()) {
throw new ResourceNotFoundException("Pet image not found for id: " + id);
}
if (!canViewPetImage(pet, requesterUserId, requesterRole)) {
if (!canViewPet(pet, new CurrentViewer(requesterUserId, requesterRole))) {
throw new ForbiddenImageAccessException();
}
Resource resource = catalogImageStorageService.loadPetImage(pet.getImageUrl());
@@ -122,14 +151,21 @@ public class PetService {
return "available".equalsIgnoreCase(normalizeStatus(pet.getPetStatus()));
}
private boolean canViewPetImage(Pet pet, Long requesterUserId, User.Role requesterRole) {
private boolean canViewPet(Pet pet, CurrentViewer viewer) {
if (isPubliclyVisible(pet)) {
return true;
}
if (requesterRole == User.Role.STAFF || requesterRole == User.Role.ADMIN) {
if (viewer != null && (viewer.role() == User.Role.STAFF || viewer.role() == User.Role.ADMIN)) {
return true;
}
if (requesterUserId == null) {
if (viewer == null || viewer.userId() == null) {
return false;
}
return isAdoptedByUser(pet, viewer.userId());
}
private boolean isAdoptedByUser(Pet pet, Long userId) {
if (userId == null) {
return false;
}
if (!"adopted".equalsIgnoreCase(normalizeStatus(pet.getPetStatus()))) {
@@ -137,10 +173,22 @@ public class PetService {
}
return adoptionRepository.findFirstByPet_IdAndAdoptionStatusOrderByAdoptionDateDesc(pet.getPetId(), "Completed")
.map(Adoption::getCustomer)
.map(customer -> requesterUserId.equals(customer.getUserId()))
.map(customer -> userId.equals(customer.getUserId()))
.orElse(false);
}
private CurrentViewer getCurrentViewer() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !authentication.isAuthenticated()) {
return null;
}
Object principal = authentication.getPrincipal();
if (principal instanceof AppPrincipal appPrincipal) {
return new CurrentViewer(appPrincipal.getUserId(), appPrincipal.getRole());
}
return null;
}
private Pet findPet(Long id) {
return petRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Pet not found with id: " + id));
@@ -177,6 +225,14 @@ public class PetService {
return status == null ? "" : status.trim();
}
private boolean isAllowedPublicStatus(String status) {
return status == null || "available".equalsIgnoreCase(status);
}
private boolean isAllowedCustomerStatus(String status) {
return status == null || "available".equalsIgnoreCase(status) || "adopted".equalsIgnoreCase(status);
}
private String normalizeFilter(String value) {
if (value == null) {
return null;
@@ -203,6 +259,9 @@ public class PetService {
public record ImagePayload(Resource resource, MediaType mediaType) {
}
private record CurrentViewer(Long userId, User.Role role) {
}
public static class ForbiddenImageAccessException extends RuntimeException {
}
}

View File

@@ -2,12 +2,16 @@ package com.petshop.backend.service;
import com.petshop.backend.entity.Appointment;
import com.petshop.backend.entity.Customer;
import com.petshop.backend.entity.CustomerPet;
import com.petshop.backend.entity.Pet;
import com.petshop.backend.entity.Service;
import com.petshop.backend.entity.StoreLocation;
import com.petshop.backend.entity.User;
import com.petshop.backend.repository.AppointmentRepository;
import com.petshop.backend.repository.CustomerPetRepository;
import com.petshop.backend.repository.CustomerRepository;
import com.petshop.backend.repository.EmployeeRepository;
import com.petshop.backend.repository.EmployeeStoreRepository;
import com.petshop.backend.repository.PetRepository;
import com.petshop.backend.repository.ServiceRepository;
import com.petshop.backend.repository.StoreRepository;
@@ -29,6 +33,7 @@ import java.util.List;
import java.util.Optional;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -44,6 +49,9 @@ class AppointmentServiceTest {
@Mock
private CustomerRepository customerRepository;
@Mock
private CustomerPetRepository customerPetRepository;
@Mock
private PetRepository petRepository;
@@ -56,6 +64,12 @@ class AppointmentServiceTest {
@Mock
private UserRepository userRepository;
@Mock
private EmployeeRepository employeeRepository;
@Mock
private EmployeeStoreRepository employeeStoreRepository;
@InjectMocks
private AppointmentService appointmentService;
@@ -64,6 +78,7 @@ class AppointmentServiceTest {
private Service grooming;
private Service nailTrim;
private Pet pet;
private CustomerPet customerPet;
private LocalDate date;
@BeforeEach
@@ -91,6 +106,11 @@ class AppointmentServiceTest {
pet.setPetId(1L);
pet.setPetName("Milo");
customerPet = new CustomerPet();
customerPet.setCustomerPetId(11L);
customerPet.setPetName("Milo Jr");
customerPet.setCustomer(customer);
date = LocalDate.now().plusDays(1);
}
@@ -144,14 +164,7 @@ class AppointmentServiceTest {
user.setRole(User.Role.CUSTOMER);
user.setTokenVersion(0);
when(userRepository.findById(10L)).thenReturn(Optional.of(user));
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(
new com.petshop.backend.security.AppPrincipal(10L, "pat", User.Role.CUSTOMER, 0),
"n/a",
List.of(new SimpleGrantedAuthority("ROLE_CUSTOMER"))
)
);
setAuthentication(10L, User.Role.CUSTOMER);
when(appointmentRepository.findById(1L)).thenReturn(Optional.of(existing));
when(customerRepository.findById(1L)).thenReturn(Optional.of(customer));
@@ -176,6 +189,78 @@ class AppointmentServiceTest {
assertEquals("Booked", response.getAppointmentStatus());
}
@Test
void createAppointmentRejectsCustomerPetOwnedByDifferentCustomer() {
User adminUser = new User();
adminUser.setId(99L);
adminUser.setUsername("admin");
adminUser.setRole(User.Role.ADMIN);
adminUser.setTokenVersion(0);
when(userRepository.findById(99L)).thenReturn(Optional.of(adminUser));
setAuthentication(99L, User.Role.ADMIN);
Customer otherCustomer = new Customer();
otherCustomer.setCustomerId(2L);
CustomerPet otherCustomerPet = new CustomerPet();
otherCustomerPet.setCustomerPetId(22L);
otherCustomerPet.setCustomer(otherCustomer);
otherCustomerPet.setPetName("Not Yours");
when(customerRepository.findById(1L)).thenReturn(Optional.of(customer));
when(storeRepository.findById(1L)).thenReturn(Optional.of(store));
when(serviceRepository.findById(1L)).thenReturn(Optional.of(grooming));
when(appointmentRepository.findByStoreAndDate(1L, date)).thenReturn(List.of());
when(customerPetRepository.findById(22L)).thenReturn(Optional.of(otherCustomerPet));
var request = new com.petshop.backend.dto.appointment.AppointmentRequest();
request.setCustomerId(1L);
request.setStoreId(1L);
request.setServiceId(1L);
request.setAppointmentDate(date);
request.setAppointmentTime(LocalTime.of(10, 0));
request.setAppointmentStatus("Booked");
request.setCustomerPetIds(List.of(22L));
assertThrows(IllegalArgumentException.class, () -> appointmentService.createAppointment(request));
}
@Test
void createAppointmentAllowsCustomerOwnedCustomerPet() {
User adminUser = new User();
adminUser.setId(99L);
adminUser.setUsername("admin");
adminUser.setRole(User.Role.ADMIN);
adminUser.setTokenVersion(0);
when(userRepository.findById(99L)).thenReturn(Optional.of(adminUser));
setAuthentication(99L, User.Role.ADMIN);
when(customerRepository.findById(1L)).thenReturn(Optional.of(customer));
when(storeRepository.findById(1L)).thenReturn(Optional.of(store));
when(serviceRepository.findById(1L)).thenReturn(Optional.of(grooming));
when(appointmentRepository.findByStoreAndDate(1L, date)).thenReturn(List.of());
when(customerPetRepository.findById(11L)).thenReturn(Optional.of(customerPet));
when(appointmentRepository.save(any(Appointment.class))).thenAnswer(invocation -> {
Appointment appointment = invocation.getArgument(0);
appointment.setAppointmentId(99L);
return appointment;
});
var request = new com.petshop.backend.dto.appointment.AppointmentRequest();
request.setCustomerId(1L);
request.setStoreId(1L);
request.setServiceId(1L);
request.setAppointmentDate(date);
request.setAppointmentTime(LocalTime.of(10, 0));
request.setAppointmentStatus("Booked");
request.setCustomerPetIds(List.of(11L));
var response = appointmentService.createAppointment(request);
assertEquals(99L, response.getAppointmentId());
assertEquals(1L, response.getCustomerId());
}
private Appointment appointment(Long id, LocalDate date, LocalTime time, Service service, StoreLocation storeLocation) {
Appointment appointment = new Appointment();
appointment.setAppointmentId(id);
@@ -188,4 +273,14 @@ class AppointmentServiceTest {
appointment.setPets(Set.of());
return appointment;
}
private void setAuthentication(Long userId, User.Role role) {
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(
new com.petshop.backend.security.AppPrincipal(userId, "user", role, 0),
"n/a",
List.of(new SimpleGrantedAuthority("ROLE_" + role.name()))
)
);
}
}

View File

@@ -0,0 +1,165 @@
package com.petshop.backend.service;
import com.petshop.backend.entity.Adoption;
import com.petshop.backend.entity.Customer;
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 com.petshop.backend.security.AppPrincipal;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class PetServiceTest {
@Mock
private PetRepository petRepository;
@Mock
private AdoptionRepository adoptionRepository;
@Mock
private CatalogImageStorageService catalogImageStorageService;
@InjectMocks
private PetService petService;
@AfterEach
void tearDown() {
SecurityContextHolder.clearContext();
}
@Test
void getAllPetsAnonymousReturnsOnlyPublicPets() {
Pageable pageable = PageRequest.of(0, 10);
Pet availablePet = pet(1L, "Buddy", "Available");
when(petRepository.searchPublicPets(null, null, pageable)).thenReturn(new PageImpl<>(List.of(availablePet), pageable, 1));
var result = petService.getAllPets(null, null, null, pageable);
assertEquals(1, result.getTotalElements());
assertEquals("Buddy", result.getContent().get(0).getPetName());
verify(petRepository).searchPublicPets(null, null, pageable);
verify(petRepository, never()).searchPets(null, null, null, pageable);
}
@Test
void getAllPetsAnonymousWithAdoptedStatusReturnsEmptyPage() {
Pageable pageable = PageRequest.of(0, 10);
var result = petService.getAllPets(null, null, "Adopted", pageable);
assertEquals(0, result.getTotalElements());
verify(petRepository, never()).searchPublicPets(null, null, pageable);
}
@Test
void getAllPetsCustomerReturnsVisiblePetsOnly() {
Pageable pageable = PageRequest.of(0, 10);
setAuthentication(25L, User.Role.CUSTOMER);
Pet availablePet = pet(1L, "Buddy", "Available");
Pet adoptedPet = pet(2L, "Luna", "Adopted");
when(petRepository.searchCustomerVisiblePets(25L, null, null, null, pageable))
.thenReturn(new PageImpl<>(List.of(availablePet, adoptedPet), pageable, 2));
var result = petService.getAllPets(null, null, null, pageable);
assertEquals(2, result.getTotalElements());
verify(petRepository).searchCustomerVisiblePets(25L, null, null, null, pageable);
}
@Test
void getAllPetsAdminReturnsAllPets() {
Pageable pageable = PageRequest.of(0, 10);
setAuthentication(99L, User.Role.ADMIN);
Pet availablePet = pet(1L, "Buddy", "Available");
Pet adoptedPet = pet(2L, "Luna", "Adopted");
when(petRepository.searchPets(null, null, null, pageable))
.thenReturn(new PageImpl<>(List.of(availablePet, adoptedPet), pageable, 2));
var result = petService.getAllPets(null, null, null, pageable);
assertEquals(2, result.getTotalElements());
verify(petRepository).searchPets(null, null, null, pageable);
}
@Test
void getPetByIdHidesAdoptedPetFromUnrelatedCustomer() {
setAuthentication(50L, User.Role.CUSTOMER);
Pet adoptedPet = pet(2L, "Luna", "Adopted");
when(petRepository.findById(2L)).thenReturn(Optional.of(adoptedPet));
when(adoptionRepository.findFirstByPet_IdAndAdoptionStatusOrderByAdoptionDateDesc(2L, "Completed"))
.thenReturn(Optional.of(adoption(2L, 25L)));
assertThrows(ResourceNotFoundException.class, () -> petService.getPetById(2L));
}
@Test
void getPetByIdAllowsOwnerToSeeAdoptedPet() {
setAuthentication(25L, User.Role.CUSTOMER);
Pet adoptedPet = pet(2L, "Luna", "Adopted");
when(petRepository.findById(2L)).thenReturn(Optional.of(adoptedPet));
when(adoptionRepository.findFirstByPet_IdAndAdoptionStatusOrderByAdoptionDateDesc(2L, "Completed"))
.thenReturn(Optional.of(adoption(2L, 25L)));
var result = petService.getPetById(2L);
assertEquals(2L, result.getPetId());
}
private void setAuthentication(Long userId, User.Role role) {
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(
new AppPrincipal(userId, "user", role, 0),
"n/a",
List.of(new SimpleGrantedAuthority("ROLE_" + role.name()))
)
);
}
private Pet pet(Long id, String name, String status) {
Pet pet = new Pet();
pet.setPetId(id);
pet.setPetName(name);
pet.setPetSpecies("Cat");
pet.setPetBreed("Mixed");
pet.setPetAge(2);
pet.setPetStatus(status);
pet.setPetPrice(java.math.BigDecimal.TEN);
return pet;
}
private Adoption adoption(Long petId, Long userId) {
Adoption adoption = new Adoption();
Pet pet = new Pet();
pet.setPetId(petId);
adoption.setPet(pet);
Customer customer = new Customer();
customer.setCustomerId(1L);
customer.setUserId(userId);
adoption.setCustomer(customer);
adoption.setAdoptionStatus("Completed");
return adoption;
}
}

View File

@@ -6,6 +6,7 @@ import java.util.List;
public class AppointmentRequest {
private List<Long> petIds;
private List<Long> customerPetIds;
private Long customerId;
private Long storeId;
private Long serviceId;
@@ -24,6 +25,14 @@ public class AppointmentRequest {
this.petIds = petIds;
}
public List<Long> getCustomerPetIds() {
return customerPetIds;
}
public void setCustomerPetIds(List<Long> customerPetIds) {
this.customerPetIds = customerPetIds;
}
public Long getCustomerId() {
return customerId;
}

View File

@@ -12,6 +12,8 @@ public class AppointmentResponse {
private Long serviceId;
private java.util.List<String> petNames;
private java.util.List<Long> petIds;
private java.util.List<String> customerPetNames;
private java.util.List<Long> customerPetIds;
private String serviceName;
private LocalDate appointmentDate;
private LocalTime appointmentTime;
@@ -84,6 +86,22 @@ public class AppointmentResponse {
this.petIds = petIds;
}
public java.util.List<String> getCustomerPetNames() {
return customerPetNames;
}
public void setCustomerPetNames(java.util.List<String> customerPetNames) {
this.customerPetNames = customerPetNames;
}
public java.util.List<Long> getCustomerPetIds() {
return customerPetIds;
}
public void setCustomerPetIds(java.util.List<Long> customerPetIds) {
this.customerPetIds = customerPetIds;
}
public String getServiceName() {
return serviceName;
}

View File

@@ -82,6 +82,14 @@ public class DropdownApi {
return apiClient.getObjectMapper().readValue(response, new TypeReference<List<DropdownOption>>() {});
}
public List<DropdownOption> getCustomerPets(Long customerId) throws Exception {
String response = apiClient.getRawResponse("/api/v1/dropdowns/customers/" + customerId + "/pets");
if (response == null || response.isEmpty()) {
throw new IllegalStateException("Empty response from customer pets endpoint");
}
return apiClient.getObjectMapper().readValue(response, new TypeReference<List<DropdownOption>>() {});
}
public List<DropdownOption> getStores() throws Exception {
String response = apiClient.getRawResponse("/api/v1/dropdowns/stores");
if (response == null || response.isEmpty()) {

View File

@@ -233,13 +233,18 @@ public class AppointmentController {
}
private AppointmentDTO mapToAppointmentDTO(AppointmentResponse response) {
Long petId = response.getPetIds() != null && !response.getPetIds().isEmpty() ? response.getPetIds().get(0) : null;
Long petId = response.getCustomerPetIds() != null && !response.getCustomerPetIds().isEmpty()
? response.getCustomerPetIds().get(0)
: response.getPetIds() != null && !response.getPetIds().isEmpty() ? response.getPetIds().get(0) : null;
String petName = response.getCustomerPetNames() != null && !response.getCustomerPetNames().isEmpty()
? String.join(", ", response.getCustomerPetNames())
: String.join(", ", response.getPetNames());
return new AppointmentDTO(
response.getAppointmentId().intValue(),
response.getCustomerId() != null ? response.getCustomerId().intValue() : 0,
response.getCustomerName(),
petId != null ? petId.intValue() : 0,
String.join(", ", response.getPetNames()),
petName,
response.getServiceId() != null ? response.getServiceId().intValue() : 0,
response.getServiceName(),
response.getAppointmentDate().toString(),

View File

@@ -19,6 +19,7 @@ import org.example.petshopdesktop.auth.UserSession;
import org.example.petshopdesktop.util.ActivityLogger;
import java.time.LocalTime;
import java.time.LocalDate;
import java.util.Collections;
import java.util.List;
@@ -50,6 +51,7 @@ public class AppointmentDialogController {
private String mode = null; // Add | Edit
private AppointmentDTO selectedAppointment = null;
private Long pendingPetSelectionId = null;
private ObservableList<String> statusList =
FXCollections.observableArrayList(
@@ -77,7 +79,6 @@ public class AppointmentDialogController {
try {
List<DropdownOption> services = DropdownApi.getInstance().getServices();
List<DropdownOption> customers = DropdownApi.getInstance().getCustomers();
List<DropdownOption> pets = DropdownApi.getInstance().getPets();
Platform.runLater(() -> {
if (services != null) {
@@ -86,9 +87,6 @@ public class AppointmentDialogController {
if (customers != null) {
cbCustomer.setItems(FXCollections.observableArrayList(customers));
}
if (pets != null) {
cbPet.setItems(FXCollections.observableArrayList(pets));
}
syncSelectedAppointment();
});
} catch (Exception e) {
@@ -103,6 +101,12 @@ public class AppointmentDialogController {
}).start();
cbAppointmentStatus.setItems(statusList);
cbPet.setDisable(true);
cbPet.setPromptText("Select a customer first");
cbCustomer.setPromptText("Select a customer");
cbService.setPromptText("Select a service");
dpAppointmentDate.setValue(LocalDate.now().plusDays(1));
cbAppointmentStatus.setValue("Booked");
// Hours 9 AM - 5 PM
for (int i = 9; i <= 17; i++) {
@@ -157,6 +161,20 @@ public class AppointmentDialogController {
}
});
cbCustomer.valueProperty().addListener((obs, oldValue, newValue) -> {
Long customerId = newValue != null ? newValue.getId() : null;
cbPet.setValue(null);
cbPet.setItems(FXCollections.observableArrayList());
cbPet.setDisable(customerId == null);
if (customerId != null) {
cbPet.setPromptText("Loading customer pets...");
loadCustomerPets(customerId);
} else {
cbPet.setPromptText("Select a customer first");
pendingPetSelectionId = null;
}
});
btnSave.setOnMouseClicked(this::buttonSaveClicked);
btnCancel.setOnMouseClicked(this::closeStage);
}
@@ -199,11 +217,10 @@ public class AppointmentDialogController {
});
cbCustomer.getItems().forEach(c -> {
if (c.getId() != null && c.getId().longValue() == appt.getCustomerId()) cbCustomer.setValue(c);
});
cbPet.getItems().forEach(p -> {
if (p.getId() != null && p.getId().longValue() == appt.getPetId()) cbPet.setValue(p);
if (c.getId() != null && c.getId().longValue() == appt.getCustomerId()) {
pendingPetSelectionId = (long) appt.getPetId();
cbCustomer.setValue(c);
}
});
}
@@ -233,7 +250,7 @@ public class AppointmentDialogController {
}
AppointmentRequest request = new AppointmentRequest();
request.setPetIds(Collections.singletonList(cbPet.getValue().getId()));
request.setCustomerPetIds(Collections.singletonList(cbPet.getValue().getId()));
request.setCustomerId(cbCustomer.getValue().getId());
request.setStoreId(storeId);
request.setServiceId(cbService.getValue().getId());
@@ -288,4 +305,46 @@ public class AppointmentDialogController {
displayAppointmentDetails(selectedAppointment);
}
}
private void loadCustomerPets(Long customerId) {
new Thread(() -> {
try {
List<DropdownOption> pets = DropdownApi.getInstance().getCustomerPets(customerId);
Platform.runLater(() -> {
cbPet.setItems(FXCollections.observableArrayList(pets));
cbPet.setDisable(pets == null || pets.isEmpty());
cbPet.setPromptText(pets == null || pets.isEmpty() ? "No pets for selected customer" : "Select a pet");
if (pendingPetSelectionId != null) {
boolean matched = false;
for (DropdownOption pet : cbPet.getItems()) {
if (pet.getId() != null && pet.getId().equals(pendingPetSelectionId)) {
cbPet.setValue(pet);
matched = true;
break;
}
}
if (!matched && selectedAppointment != null && selectedAppointment.getPetName() != null && !selectedAppointment.getPetName().isBlank()) {
DropdownOption legacy = new DropdownOption();
legacy.setId(pendingPetSelectionId);
legacy.setLabel(selectedAppointment.getPetName() + " (legacy appointment pet)");
cbPet.getItems().add(0, legacy);
cbPet.setValue(legacy);
cbPet.setDisable(false);
}
pendingPetSelectionId = null;
}
});
} catch (Exception ex) {
Platform.runLater(() -> {
ActivityLogger.getInstance().logException(
"AppointmentDialogController.loadCustomerPets",
ex,
"Loading customer pets for appointment dialog");
cbPet.setDisable(true);
cbPet.setPromptText("Unable to load pets");
showError("Error loading pets for selected customer");
});
}
}).start();
}
}