diff --git a/backend/src/main/java/com/petshop/backend/repository/AppointmentRepository.java b/backend/src/main/java/com/petshop/backend/repository/AppointmentRepository.java index 5c7b6ec0..2a78244b 100644 --- a/backend/src/main/java/com/petshop/backend/repository/AppointmentRepository.java +++ b/backend/src/main/java/com/petshop/backend/repository/AppointmentRepository.java @@ -36,4 +36,7 @@ public interface AppointmentRepository extends JpaRepository "LOWER(a.service.serviceName) LIKE LOWER(CONCAT('%', :q, '%')) OR " + "LOWER(p.petName) LIKE LOWER(CONCAT('%', :q, '%')))") Page searchAppointmentsByCustomer(@Param("customerId") Long customerId, @Param("q") String query, Pageable pageable); + + @Query("SELECT a FROM Appointment a JOIN FETCH a.service WHERE a.employee.employeeId = :employeeId AND a.appointmentDate = :date AND LOWER(a.appointmentStatus) <> 'cancelled'") + List findByEmployeeEmployeeIdAndAppointmentDate(@Param("employeeId") Long employeeId, @Param("date") LocalDate date); } 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 8c0e05d8..310c539d 100644 --- a/backend/src/main/java/com/petshop/backend/service/AppointmentService.java +++ b/backend/src/main/java/com/petshop/backend/service/AppointmentService.java @@ -110,14 +110,10 @@ public class AppointmentService { com.petshop.backend.entity.Service service = serviceRepository.findById(request.getServiceId()) .orElseThrow(() -> new ResourceNotFoundException("Service not found with id: " + request.getServiceId())); - validateStoreAccess(store.getStoreId(), authenticatedUser); - validateAvailability(store, service, request.getAppointmentDate(), request.getAppointmentTime(), null); - boolean hasPetIds = request.getPetIds() != null && !request.getPetIds().isEmpty(); boolean hasCustomerPetIds = request.getCustomerPetIds() != null && !request.getCustomerPetIds().isEmpty(); if (!hasPetIds && !hasCustomerPetIds) { - throw new IllegalArgumentException("Please specify at least one pet."); } @@ -125,6 +121,9 @@ public class AppointmentService { Set customerPets = hasCustomerPetIds ? fetchCustomerPets(request.getCustomerPetIds(), customer.getCustomerId()) : new HashSet<>(); Employee employee = resolveAppointmentEmployee(request.getEmployeeId(), store.getStoreId()); + validateStoreAccess(store.getStoreId(), authenticatedUser); + validateAvailability(employee, service, request.getAppointmentDate(), request.getAppointmentTime(), null); + Appointment appointment = new Appointment(); appointment.setCustomer(customer); appointment.setStore(store); @@ -158,14 +157,10 @@ public class AppointmentService { com.petshop.backend.entity.Service service = serviceRepository.findById(request.getServiceId()) .orElseThrow(() -> new ResourceNotFoundException("Service not found with id: " + request.getServiceId())); - validateStoreAccess(store.getStoreId(), authenticatedUser); - validateAvailability(store, service, request.getAppointmentDate(), request.getAppointmentTime(), id); - boolean hasPetIds = request.getPetIds() != null && !request.getPetIds().isEmpty(); boolean hasCustomerPetIds = request.getCustomerPetIds() != null && !request.getCustomerPetIds().isEmpty(); if (!hasPetIds && !hasCustomerPetIds) { - throw new IllegalArgumentException("Please specify at least one pet."); } @@ -173,6 +168,9 @@ public class AppointmentService { Set customerPets = hasCustomerPetIds ? fetchCustomerPets(request.getCustomerPetIds(), customer.getCustomerId()) : new HashSet<>(); Employee employee = resolveAppointmentEmployee(request.getEmployeeId(), store.getStoreId()); + validateStoreAccess(store.getStoreId(), authenticatedUser); + validateAvailability(employee, service, request.getAppointmentDate(), request.getAppointmentTime(), id); + appointment.setCustomer(customer); appointment.setStore(store); appointment.setService(service); @@ -201,7 +199,6 @@ public class AppointmentService { } @Transactional(readOnly = true) - public List checkAvailability(Long storeId, Long serviceId, LocalDate date) { storeRepository.findById(storeId) .orElseThrow(() -> new ResourceNotFoundException("Store not found with id: " + storeId)); @@ -209,16 +206,10 @@ public class AppointmentService { com.petshop.backend.entity.Service service = serviceRepository.findById(serviceId) .orElseThrow(() -> new ResourceNotFoundException("Service not found with id: " + serviceId)); - - //-------------------------------------------------------------------------- - // CHANGED: filter by serviceId too - List existingAppointments = appointmentRepository - .findByStoreAndDate(storeId, date) - .stream() - .filter(a -> a.getService().getServiceId().equals(serviceId)) + List assignableEmployees = employeeStoreRepository.findActiveByStoreStoreIdOrderByEmployeeEmployeeIdAsc(storeId).stream() + .filter(es -> isAssignableEmployee(es.getEmployee())) + .map(EmployeeStore::getEmployee) .collect(Collectors.toList()); - // ------------------------------------------------------- - List availableSlots = new ArrayList<>(); LocalTime startTime = LocalTime.of(9, 0); @@ -227,7 +218,13 @@ public class AppointmentService { LocalTime currentTime = startTime; while (!currentTime.isAfter(latestStart)) { - if (isSlotAvailable(existingAppointments, service, currentTime, null)) { + final LocalTime slotTime = currentTime; + boolean anyEmployeeAvailable = assignableEmployees.stream().anyMatch(emp -> { + List empAppointments = appointmentRepository.findByEmployeeEmployeeIdAndAppointmentDate(emp.getEmployeeId(), date); + return isSlotAvailable(empAppointments, service, slotTime, null); + }); + + if (anyEmployeeAvailable) { availableSlots.add(currentTime.toString()); } currentTime = currentTime.plusMinutes(30); @@ -343,15 +340,11 @@ public class AppointmentService { } //------------------------------------ - private void validateAvailability(StoreLocation store, com.petshop.backend.entity.Service service, LocalDate date, LocalTime time, Long appointmentIdToIgnore) { - // Filter by same service only - different services can run at same time + private void validateAvailability(Employee employee, com.petshop.backend.entity.Service service, LocalDate date, LocalTime time, Long appointmentIdToIgnore) { List existingAppointments = appointmentRepository - .findByStoreAndDate(store.getStoreId(), date) - .stream() - .filter(a -> a.getService().getServiceId().equals(service.getServiceId())) - .collect(Collectors.toList()); + .findByEmployeeEmployeeIdAndAppointmentDate(employee.getEmployeeId(), date); if (!isSlotAvailable(existingAppointments, service, time, appointmentIdToIgnore)) { - throw new IllegalArgumentException("Appointment time is not available for the selected store and service"); + throw new IllegalArgumentException("The selected employee is already booked for this time slot"); } } diff --git a/backend/src/main/resources/db/migration/V17__normalize_appointment_pets.sql b/backend/src/main/resources/db/migration/V17__normalize_appointment_pets.sql new file mode 100644 index 00000000..00d34751 --- /dev/null +++ b/backend/src/main/resources/db/migration/V17__normalize_appointment_pets.sql @@ -0,0 +1,28 @@ +-- V17: Normalize legacy appointmentPet data into customer_pet and appointment_customer_pet + +-- Step 1: Ensure a customer_pet exists for every pet linked in appointmentPet +-- Note: pet species and breed might be null in pet table, but we copy them over if present +INSERT INTO customer_pet (customer_id, pet_name, species, breed) +SELECT DISTINCT a.customerId, p.petName, p.petSpecies, p.petBreed +FROM appointmentPet ap +JOIN appointment a ON a.appointmentId = ap.appointmentId +JOIN pet p ON p.petId = ap.petId +WHERE NOT EXISTS ( + SELECT 1 FROM customer_pet cp + WHERE cp.customer_id = a.customerId AND cp.pet_name = p.petName +); + +-- Step 2: Link the appointment to the customer_pet +INSERT INTO appointment_customer_pet (appointment_id, customer_pet_id) +SELECT ap.appointmentId, cp.customer_pet_id +FROM appointmentPet ap +JOIN appointment a ON a.appointmentId = ap.appointmentId +JOIN pet p ON p.petId = ap.petId +JOIN customer_pet cp ON cp.customer_id = a.customerId AND cp.pet_name = p.petName +WHERE NOT EXISTS ( + SELECT 1 FROM appointment_customer_pet acp + WHERE acp.appointment_id = ap.appointmentId AND acp.customer_pet_id = cp.customer_pet_id +); + +-- Step 3: Remove the old legacy relationships so it strictly uses the new ones +DELETE FROM appointmentPet; 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 aecb5644..3e6f2d89 100644 --- a/backend/src/test/java/com/petshop/backend/service/AppointmentServiceTest.java +++ b/backend/src/test/java/com/petshop/backend/service/AppointmentServiceTest.java @@ -130,29 +130,34 @@ class AppointmentServiceTest { } @Test - void checkAvailabilityAllowsDifferentServicesAtSameTime() { + void checkAvailabilityAllowsConcurrentAppointmentsIfAnotherEmployeeFree() { + Employee employee2 = new Employee(); + employee2.setEmployeeId(8L); + employee2.setUserId(8L); + employee2.setFirstName("Bob"); + employee2.setIsActive(true); + + User staffUser2 = new User(); + staffUser2.setId(8L); + staffUser2.setRole(User.Role.STAFF); + staffUser2.setActive(true); + when(userRepository.findById(8L)).thenReturn(Optional.of(staffUser2)); + Appointment existing = appointment(1L, date, LocalTime.of(10, 0), grooming, store); when(storeRepository.findById(1L)).thenReturn(Optional.of(store)); when(serviceRepository.findById(2L)).thenReturn(Optional.of(nailTrim)); - when(appointmentRepository.findByStoreAndDate(1L, date)).thenReturn(List.of(existing)); + + when(employeeStoreRepository.findActiveByStoreStoreIdOrderByEmployeeEmployeeIdAsc(1L)) + .thenReturn(List.of(new EmployeeStore(employee, store), new EmployeeStore(employee2, store))); + + when(appointmentRepository.findByEmployeeEmployeeIdAndAppointmentDate(7L, date)).thenReturn(List.of(existing)); + when(appointmentRepository.findByEmployeeEmployeeIdAndAppointmentDate(8L, date)).thenReturn(List.of()); List slots = appointmentService.checkAvailability(1L, 2L, date); assertTrue(slots.contains("10:00")); } - @Test - void checkAvailabilityBlocksSameServiceAtSameTime() { - Appointment existing = appointment(1L, date, LocalTime.of(10, 0), grooming, store); - when(storeRepository.findById(1L)).thenReturn(Optional.of(store)); - when(serviceRepository.findById(1L)).thenReturn(Optional.of(grooming)); - when(appointmentRepository.findByStoreAndDate(1L, date)).thenReturn(List.of(existing)); - - List slots = appointmentService.checkAvailability(1L, 1L, date); - - assertFalse(slots.contains("10:00")); - } - @Test void createAppointmentRejectsCustomerPetOwnedByDifferentCustomerForStaff() { setAuthentication(7L, User.Role.STAFF); @@ -172,7 +177,7 @@ class AppointmentServiceTest { when(serviceRepository.findById(1L)).thenReturn(Optional.of(grooming)); when(employeeStoreRepository.findActiveByStoreStoreIdOrderByEmployeeEmployeeIdAsc(1L)) .thenReturn(List.of(new EmployeeStore(employee, store))); - when(appointmentRepository.findByStoreAndDate(1L, date)).thenReturn(List.of()); + when(appointmentRepository.findByEmployeeEmployeeIdAndAppointmentDate(7L, date)).thenReturn(List.of()); when(customerPetRepository.findById(22L)).thenReturn(Optional.of(otherCustomerPet)); var request = new com.petshop.backend.dto.appointment.AppointmentRequest(); @@ -204,7 +209,7 @@ class AppointmentServiceTest { when(serviceRepository.findById(1L)).thenReturn(Optional.of(grooming)); when(employeeStoreRepository.findActiveByStoreStoreIdOrderByEmployeeEmployeeIdAsc(1L)) .thenReturn(List.of(new EmployeeStore(employee, store))); - when(appointmentRepository.findByStoreAndDate(1L, date)).thenReturn(List.of()); + when(appointmentRepository.findByEmployeeEmployeeIdAndAppointmentDate(7L, date)).thenReturn(List.of()); when(customerPetRepository.findById(22L)).thenReturn(Optional.of(otherCustomerPet)); var request = new com.petshop.backend.dto.appointment.AppointmentRequest(); @@ -245,7 +250,7 @@ class AppointmentServiceTest { when(storeRepository.findById(1L)).thenReturn(Optional.of(store)); when(serviceRepository.findById(1L)).thenReturn(Optional.of(grooming)); when(employeeRepository.findById(8L)).thenReturn(Optional.of(adminEmployee)); - when(appointmentRepository.findByStoreAndDate(1L, date)).thenReturn(List.of()); + when(appointmentRepository.findByEmployeeEmployeeIdAndAppointmentDate(7L, date)).thenReturn(List.of()); when(customerPetRepository.findById(11L)).thenReturn(Optional.of(customerPet)); when(employeeStoreRepository.findActiveByStoreStoreIdOrderByEmployeeEmployeeIdAsc(1L)) .thenReturn(List.of(new EmployeeStore(adminEmployee, store), new EmployeeStore(employee, store))); @@ -282,7 +287,7 @@ class AppointmentServiceTest { when(storeRepository.findById(1L)).thenReturn(Optional.of(store)); when(serviceRepository.findById(1L)).thenReturn(Optional.of(grooming)); when(employeeRepository.findById(7L)).thenReturn(Optional.of(employee)); - when(appointmentRepository.findByStoreAndDate(1L, date)).thenReturn(List.of()); + when(appointmentRepository.findByEmployeeEmployeeIdAndAppointmentDate(7L, date)).thenReturn(List.of()); when(customerPetRepository.findById(11L)).thenReturn(Optional.of(customerPet)); when(employeeStoreRepository.findActiveByStoreStoreIdOrderByEmployeeEmployeeIdAsc(1L)) .thenReturn(List.of(new EmployeeStore(employee, store)));