Fix appointment ownership

This commit is contained in:
2026-04-02 10:10:04 -06:00
parent ca06f6c8b3
commit 259770ce69
9 changed files with 226 additions and 26 deletions

View File

@@ -1,10 +1,12 @@
package com.petshop.backend.controller; package com.petshop.backend.controller;
import com.petshop.backend.dto.common.DropdownOption; import com.petshop.backend.dto.common.DropdownOption;
import com.petshop.backend.entity.CustomerPet;
import com.petshop.backend.repository.*; import com.petshop.backend.repository.*;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping; 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.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
@@ -17,6 +19,7 @@ public class DropdownController {
private final PetRepository petRepository; private final PetRepository petRepository;
private final CustomerRepository customerRepository; private final CustomerRepository customerRepository;
private final CustomerPetRepository customerPetRepository;
private final ServiceRepository serviceRepository; private final ServiceRepository serviceRepository;
private final ProductRepository productRepository; private final ProductRepository productRepository;
private final CategoryRepository categoryRepository; private final CategoryRepository categoryRepository;
@@ -24,11 +27,13 @@ public class DropdownController {
private final SupplierRepository supplierRepository; private final SupplierRepository supplierRepository;
public DropdownController(PetRepository petRepository, CustomerRepository customerRepository, public DropdownController(PetRepository petRepository, CustomerRepository customerRepository,
ServiceRepository serviceRepository, ProductRepository productRepository, CustomerPetRepository customerPetRepository,
CategoryRepository categoryRepository, StoreRepository storeRepository, ServiceRepository serviceRepository, ProductRepository productRepository,
SupplierRepository supplierRepository) { CategoryRepository categoryRepository, StoreRepository storeRepository,
SupplierRepository supplierRepository) {
this.petRepository = petRepository; this.petRepository = petRepository;
this.customerRepository = customerRepository; this.customerRepository = customerRepository;
this.customerPetRepository = customerPetRepository;
this.serviceRepository = serviceRepository; this.serviceRepository = serviceRepository;
this.productRepository = productRepository; this.productRepository = productRepository;
this.categoryRepository = categoryRepository; 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") @GetMapping("/services")
public ResponseEntity<List<DropdownOption>> getServices() { public ResponseEntity<List<DropdownOption>> getServices() {
return ResponseEntity.ok( return ResponseEntity.ok(
@@ -123,4 +138,10 @@ public class DropdownController {
.collect(Collectors.toList()) .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> findByCustomerCustomerIdOrderByCreatedAtDesc(Long customerId);
List<CustomerPet> findByCustomerCustomerIdOrderByPetNameAsc(Long customerId);
Optional<CustomerPet> findByCustomerPetIdAndCustomerCustomerId(Long customerPetId, Long customerId); Optional<CustomerPet> findByCustomerPetIdAndCustomerCustomerId(Long customerPetId, Long customerId);
} }

View File

@@ -120,7 +120,7 @@ public class AppointmentService {
} }
Set<Pet> pets = hasPetIds ? fetchPets(request.getPetIds()) : new HashSet<>(); 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 appointment = new Appointment();
appointment.setCustomer(customer); appointment.setCustomer(customer);
@@ -164,7 +164,7 @@ public class AppointmentService {
} }
Set<Pet> pets = hasPetIds ? fetchPets(request.getPetIds()) : new HashSet<>(); 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.setCustomer(customer);
appointment.setStore(store); appointment.setStore(store);
@@ -247,11 +247,14 @@ public class AppointmentService {
return pets; return pets;
} }
private Set<CustomerPet> fetchCustomerPets(List<Long> customerPetIds) { private Set<CustomerPet> fetchCustomerPets(List<Long> customerPetIds, Long customerId) {
Set<CustomerPet> customerPets = new HashSet<>(); Set<CustomerPet> customerPets = new HashSet<>();
for (Long customerPetId : customerPetIds) { for (Long customerPetId : customerPetIds) {
CustomerPet customerPet = customerPetRepository.findById(customerPetId) CustomerPet customerPet = customerPetRepository.findById(customerPetId)
.orElseThrow(() -> new ResourceNotFoundException("Customer pet not found with id: " + 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); customerPets.add(customerPet);
} }

View File

@@ -2,12 +2,16 @@ package com.petshop.backend.service;
import com.petshop.backend.entity.Appointment; import com.petshop.backend.entity.Appointment;
import com.petshop.backend.entity.Customer; import com.petshop.backend.entity.Customer;
import com.petshop.backend.entity.CustomerPet;
import com.petshop.backend.entity.Pet; import com.petshop.backend.entity.Pet;
import com.petshop.backend.entity.Service; import com.petshop.backend.entity.Service;
import com.petshop.backend.entity.StoreLocation; import com.petshop.backend.entity.StoreLocation;
import com.petshop.backend.entity.User; import com.petshop.backend.entity.User;
import com.petshop.backend.repository.AppointmentRepository; import com.petshop.backend.repository.AppointmentRepository;
import com.petshop.backend.repository.CustomerPetRepository;
import com.petshop.backend.repository.CustomerRepository; 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.PetRepository;
import com.petshop.backend.repository.ServiceRepository; import com.petshop.backend.repository.ServiceRepository;
import com.petshop.backend.repository.StoreRepository; import com.petshop.backend.repository.StoreRepository;
@@ -29,6 +33,7 @@ import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.Set; 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.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -44,6 +49,9 @@ class AppointmentServiceTest {
@Mock @Mock
private CustomerRepository customerRepository; private CustomerRepository customerRepository;
@Mock
private CustomerPetRepository customerPetRepository;
@Mock @Mock
private PetRepository petRepository; private PetRepository petRepository;
@@ -56,6 +64,12 @@ class AppointmentServiceTest {
@Mock @Mock
private UserRepository userRepository; private UserRepository userRepository;
@Mock
private EmployeeRepository employeeRepository;
@Mock
private EmployeeStoreRepository employeeStoreRepository;
@InjectMocks @InjectMocks
private AppointmentService appointmentService; private AppointmentService appointmentService;
@@ -64,6 +78,7 @@ class AppointmentServiceTest {
private Service grooming; private Service grooming;
private Service nailTrim; private Service nailTrim;
private Pet pet; private Pet pet;
private CustomerPet customerPet;
private LocalDate date; private LocalDate date;
@BeforeEach @BeforeEach
@@ -91,6 +106,11 @@ class AppointmentServiceTest {
pet.setPetId(1L); pet.setPetId(1L);
pet.setPetName("Milo"); pet.setPetName("Milo");
customerPet = new CustomerPet();
customerPet.setCustomerPetId(11L);
customerPet.setPetName("Milo Jr");
customerPet.setCustomer(customer);
date = LocalDate.now().plusDays(1); date = LocalDate.now().plusDays(1);
} }
@@ -144,14 +164,7 @@ class AppointmentServiceTest {
user.setRole(User.Role.CUSTOMER); user.setRole(User.Role.CUSTOMER);
user.setTokenVersion(0); user.setTokenVersion(0);
when(userRepository.findById(10L)).thenReturn(Optional.of(user)); when(userRepository.findById(10L)).thenReturn(Optional.of(user));
setAuthentication(10L, User.Role.CUSTOMER);
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"))
)
);
when(appointmentRepository.findById(1L)).thenReturn(Optional.of(existing)); when(appointmentRepository.findById(1L)).thenReturn(Optional.of(existing));
when(customerRepository.findById(1L)).thenReturn(Optional.of(customer)); when(customerRepository.findById(1L)).thenReturn(Optional.of(customer));
@@ -176,6 +189,78 @@ class AppointmentServiceTest {
assertEquals("Booked", response.getAppointmentStatus()); 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) { private Appointment appointment(Long id, LocalDate date, LocalTime time, Service service, StoreLocation storeLocation) {
Appointment appointment = new Appointment(); Appointment appointment = new Appointment();
appointment.setAppointmentId(id); appointment.setAppointmentId(id);
@@ -188,4 +273,14 @@ class AppointmentServiceTest {
appointment.setPets(Set.of()); appointment.setPets(Set.of());
return appointment; 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

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

View File

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

View File

@@ -82,6 +82,14 @@ public class DropdownApi {
return apiClient.getObjectMapper().readValue(response, new TypeReference<List<DropdownOption>>() {}); 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 { public List<DropdownOption> getStores() throws Exception {
String response = apiClient.getRawResponse("/api/v1/dropdowns/stores"); String response = apiClient.getRawResponse("/api/v1/dropdowns/stores");
if (response == null || response.isEmpty()) { if (response == null || response.isEmpty()) {

View File

@@ -233,13 +233,18 @@ public class AppointmentController {
} }
private AppointmentDTO mapToAppointmentDTO(AppointmentResponse response) { 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( return new AppointmentDTO(
response.getAppointmentId().intValue(), response.getAppointmentId().intValue(),
response.getCustomerId() != null ? response.getCustomerId().intValue() : 0, response.getCustomerId() != null ? response.getCustomerId().intValue() : 0,
response.getCustomerName(), response.getCustomerName(),
petId != null ? petId.intValue() : 0, petId != null ? petId.intValue() : 0,
String.join(", ", response.getPetNames()), petName,
response.getServiceId() != null ? response.getServiceId().intValue() : 0, response.getServiceId() != null ? response.getServiceId().intValue() : 0,
response.getServiceName(), response.getServiceName(),
response.getAppointmentDate().toString(), response.getAppointmentDate().toString(),

View File

@@ -50,6 +50,7 @@ public class AppointmentDialogController {
private String mode = null; // Add | Edit private String mode = null; // Add | Edit
private AppointmentDTO selectedAppointment = null; private AppointmentDTO selectedAppointment = null;
private Long pendingPetSelectionId = null;
private ObservableList<String> statusList = private ObservableList<String> statusList =
FXCollections.observableArrayList( FXCollections.observableArrayList(
@@ -77,7 +78,6 @@ public class AppointmentDialogController {
try { try {
List<DropdownOption> services = DropdownApi.getInstance().getServices(); List<DropdownOption> services = DropdownApi.getInstance().getServices();
List<DropdownOption> customers = DropdownApi.getInstance().getCustomers(); List<DropdownOption> customers = DropdownApi.getInstance().getCustomers();
List<DropdownOption> pets = DropdownApi.getInstance().getPets();
Platform.runLater(() -> { Platform.runLater(() -> {
if (services != null) { if (services != null) {
@@ -86,9 +86,6 @@ public class AppointmentDialogController {
if (customers != null) { if (customers != null) {
cbCustomer.setItems(FXCollections.observableArrayList(customers)); cbCustomer.setItems(FXCollections.observableArrayList(customers));
} }
if (pets != null) {
cbPet.setItems(FXCollections.observableArrayList(pets));
}
syncSelectedAppointment(); syncSelectedAppointment();
}); });
} catch (Exception e) { } catch (Exception e) {
@@ -103,6 +100,7 @@ public class AppointmentDialogController {
}).start(); }).start();
cbAppointmentStatus.setItems(statusList); cbAppointmentStatus.setItems(statusList);
cbPet.setDisable(true);
// Hours 9 AM - 5 PM // Hours 9 AM - 5 PM
for (int i = 9; i <= 17; i++) { 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); btnSave.setOnMouseClicked(this::buttonSaveClicked);
btnCancel.setOnMouseClicked(this::closeStage); btnCancel.setOnMouseClicked(this::closeStage);
} }
@@ -199,11 +209,10 @@ public class AppointmentDialogController {
}); });
cbCustomer.getItems().forEach(c -> { cbCustomer.getItems().forEach(c -> {
if (c.getId() != null && c.getId().longValue() == appt.getCustomerId()) cbCustomer.setValue(c); if (c.getId() != null && c.getId().longValue() == appt.getCustomerId()) {
}); pendingPetSelectionId = (long) appt.getPetId();
cbCustomer.setValue(c);
cbPet.getItems().forEach(p -> { }
if (p.getId() != null && p.getId().longValue() == appt.getPetId()) cbPet.setValue(p);
}); });
} }
@@ -233,7 +242,7 @@ public class AppointmentDialogController {
} }
AppointmentRequest request = new AppointmentRequest(); AppointmentRequest request = new AppointmentRequest();
request.setPetIds(Collections.singletonList(cbPet.getValue().getId())); request.setCustomerPetIds(Collections.singletonList(cbPet.getValue().getId()));
request.setCustomerId(cbCustomer.getValue().getId()); request.setCustomerId(cbCustomer.getValue().getId());
request.setStoreId(storeId); request.setStoreId(storeId);
request.setServiceId(cbService.getValue().getId()); request.setServiceId(cbService.getValue().getId());
@@ -288,4 +297,34 @@ public class AppointmentDialogController {
displayAppointmentDetails(selectedAppointment); 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(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();
}
} }