diff --git a/backend/src/main/java/com/petshop/backend/controller/DropdownController.java b/backend/src/main/java/com/petshop/backend/controller/DropdownController.java index c942eae8..56e53a56 100644 --- a/backend/src/main/java/com/petshop/backend/controller/DropdownController.java +++ b/backend/src/main/java/com/petshop/backend/controller/DropdownController.java @@ -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, - ServiceRepository serviceRepository, ProductRepository productRepository, - CategoryRepository categoryRepository, StoreRepository storeRepository, - SupplierRepository supplierRepository) { + 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> getCustomerPets(@PathVariable Long customerId) { + return ResponseEntity.ok( + customerPetRepository.findByCustomerCustomerIdOrderByPetNameAsc(customerId).stream() + .map(this::toCustomerPetOption) + .collect(Collectors.toList()) + ); + } + @GetMapping("/services") public ResponseEntity> 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 + ")"); + } } diff --git a/backend/src/main/java/com/petshop/backend/repository/CustomerPetRepository.java b/backend/src/main/java/com/petshop/backend/repository/CustomerPetRepository.java index 8d08f8b9..4fe0ef81 100644 --- a/backend/src/main/java/com/petshop/backend/repository/CustomerPetRepository.java +++ b/backend/src/main/java/com/petshop/backend/repository/CustomerPetRepository.java @@ -12,5 +12,7 @@ public interface CustomerPetRepository extends JpaRepository List findByCustomerCustomerIdOrderByCreatedAtDesc(Long customerId); + List findByCustomerCustomerIdOrderByPetNameAsc(Long customerId); + Optional findByCustomerPetIdAndCustomerCustomerId(Long customerPetId, Long customerId); } diff --git a/backend/src/main/java/com/petshop/backend/service/AppointmentService.java b/backend/src/main/java/com/petshop/backend/service/AppointmentService.java index 67ce4f36..c5b615d3 100644 --- a/backend/src/main/java/com/petshop/backend/service/AppointmentService.java +++ b/backend/src/main/java/com/petshop/backend/service/AppointmentService.java @@ -120,7 +120,7 @@ public class AppointmentService { } Set pets = hasPetIds ? fetchPets(request.getPetIds()) : new HashSet<>(); - Set customerPets = hasCustomerPetIds ? fetchCustomerPets(request.getCustomerPetIds()) : new HashSet<>(); + Set customerPets = hasCustomerPetIds ? fetchCustomerPets(request.getCustomerPetIds(), customer.getCustomerId()) : new HashSet<>(); Appointment appointment = new Appointment(); appointment.setCustomer(customer); @@ -164,7 +164,7 @@ public class AppointmentService { } Set pets = hasPetIds ? fetchPets(request.getPetIds()) : new HashSet<>(); - Set customerPets = hasCustomerPetIds ? fetchCustomerPets(request.getCustomerPetIds()) : new HashSet<>(); + Set 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 fetchCustomerPets(List customerPetIds) { + private Set fetchCustomerPets(List customerPetIds, Long customerId) { Set 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); } diff --git a/backend/src/test/java/com/petshop/backend/service/AppointmentServiceTest.java b/backend/src/test/java/com/petshop/backend/service/AppointmentServiceTest.java index 2a1e6eed..e978fcde 100644 --- a/backend/src/test/java/com/petshop/backend/service/AppointmentServiceTest.java +++ b/backend/src/test/java/com/petshop/backend/service/AppointmentServiceTest.java @@ -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())) + ) + ); + } } diff --git a/desktop/src/main/java/org/example/petshopdesktop/api/dto/appointment/AppointmentRequest.java b/desktop/src/main/java/org/example/petshopdesktop/api/dto/appointment/AppointmentRequest.java index a81faaff..299fcae9 100644 --- a/desktop/src/main/java/org/example/petshopdesktop/api/dto/appointment/AppointmentRequest.java +++ b/desktop/src/main/java/org/example/petshopdesktop/api/dto/appointment/AppointmentRequest.java @@ -6,6 +6,7 @@ import java.util.List; public class AppointmentRequest { private List petIds; + private List customerPetIds; private Long customerId; private Long storeId; private Long serviceId; @@ -24,6 +25,14 @@ public class AppointmentRequest { this.petIds = petIds; } + public List getCustomerPetIds() { + return customerPetIds; + } + + public void setCustomerPetIds(List customerPetIds) { + this.customerPetIds = customerPetIds; + } + public Long getCustomerId() { return customerId; } diff --git a/desktop/src/main/java/org/example/petshopdesktop/api/dto/appointment/AppointmentResponse.java b/desktop/src/main/java/org/example/petshopdesktop/api/dto/appointment/AppointmentResponse.java index 1d904bd0..c71dc3f3 100644 --- a/desktop/src/main/java/org/example/petshopdesktop/api/dto/appointment/AppointmentResponse.java +++ b/desktop/src/main/java/org/example/petshopdesktop/api/dto/appointment/AppointmentResponse.java @@ -12,6 +12,8 @@ public class AppointmentResponse { private Long serviceId; private java.util.List petNames; private java.util.List petIds; + private java.util.List customerPetNames; + private java.util.List customerPetIds; private String serviceName; private LocalDate appointmentDate; private LocalTime appointmentTime; @@ -84,6 +86,22 @@ public class AppointmentResponse { this.petIds = petIds; } + public java.util.List getCustomerPetNames() { + return customerPetNames; + } + + public void setCustomerPetNames(java.util.List customerPetNames) { + this.customerPetNames = customerPetNames; + } + + public java.util.List getCustomerPetIds() { + return customerPetIds; + } + + public void setCustomerPetIds(java.util.List customerPetIds) { + this.customerPetIds = customerPetIds; + } + public String getServiceName() { return serviceName; } diff --git a/desktop/src/main/java/org/example/petshopdesktop/api/endpoints/DropdownApi.java b/desktop/src/main/java/org/example/petshopdesktop/api/endpoints/DropdownApi.java index 30fcb0b8..ec0fe4d0 100644 --- a/desktop/src/main/java/org/example/petshopdesktop/api/endpoints/DropdownApi.java +++ b/desktop/src/main/java/org/example/petshopdesktop/api/endpoints/DropdownApi.java @@ -82,6 +82,14 @@ public class DropdownApi { return apiClient.getObjectMapper().readValue(response, new TypeReference>() {}); } + public List 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>() {}); + } + public List getStores() throws Exception { String response = apiClient.getRawResponse("/api/v1/dropdowns/stores"); if (response == null || response.isEmpty()) { diff --git a/desktop/src/main/java/org/example/petshopdesktop/controllers/AppointmentController.java b/desktop/src/main/java/org/example/petshopdesktop/controllers/AppointmentController.java index bd5dc392..e310c039 100644 --- a/desktop/src/main/java/org/example/petshopdesktop/controllers/AppointmentController.java +++ b/desktop/src/main/java/org/example/petshopdesktop/controllers/AppointmentController.java @@ -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(), diff --git a/desktop/src/main/java/org/example/petshopdesktop/controllers/dialogcontrollers/AppointmentDialogController.java b/desktop/src/main/java/org/example/petshopdesktop/controllers/dialogcontrollers/AppointmentDialogController.java index 932d10d5..c4b39ba7 100644 --- a/desktop/src/main/java/org/example/petshopdesktop/controllers/dialogcontrollers/AppointmentDialogController.java +++ b/desktop/src/main/java/org/example/petshopdesktop/controllers/dialogcontrollers/AppointmentDialogController.java @@ -50,6 +50,7 @@ public class AppointmentDialogController { private String mode = null; // Add | Edit private AppointmentDTO selectedAppointment = null; + private Long pendingPetSelectionId = null; private ObservableList statusList = FXCollections.observableArrayList( @@ -77,7 +78,6 @@ public class AppointmentDialogController { try { List services = DropdownApi.getInstance().getServices(); List customers = DropdownApi.getInstance().getCustomers(); - List pets = DropdownApi.getInstance().getPets(); Platform.runLater(() -> { if (services != null) { @@ -86,9 +86,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 +100,7 @@ public class AppointmentDialogController { }).start(); cbAppointmentStatus.setItems(statusList); + cbPet.setDisable(true); // Hours 9 AM - 5 PM for (int i = 9; i <= 17; i++) { @@ -157,6 +155,18 @@ 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) { + loadCustomerPets(customerId); + } else { + pendingPetSelectionId = null; + } + }); + btnSave.setOnMouseClicked(this::buttonSaveClicked); btnCancel.setOnMouseClicked(this::closeStage); } @@ -199,11 +209,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 +242,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 +297,34 @@ public class AppointmentDialogController { displayAppointmentDetails(selectedAppointment); } } + + private void loadCustomerPets(Long customerId) { + new Thread(() -> { + try { + List pets = DropdownApi.getInstance().getCustomerPets(customerId); + Platform.runLater(() -> { + cbPet.setItems(FXCollections.observableArrayList(pets)); + cbPet.setDisable(false); + if (pendingPetSelectionId != null) { + for (DropdownOption pet : cbPet.getItems()) { + if (pet.getId() != null && pet.getId().equals(pendingPetSelectionId)) { + cbPet.setValue(pet); + break; + } + } + pendingPetSelectionId = null; + } + }); + } catch (Exception ex) { + Platform.runLater(() -> { + ActivityLogger.getInstance().logException( + "AppointmentDialogController.loadCustomerPets", + ex, + "Loading customer pets for appointment dialog"); + cbPet.setDisable(true); + showError("Error loading pets for selected customer"); + }); + } + }).start(); + } }