From 0a43dc5bebd82012ca46d5e70cdf43af76824200 Mon Sep 17 00:00:00 2001 From: Mehara Rothila Ranawaka Date: Fri, 7 Nov 2025 00:41:09 +0530 Subject: [PATCH 01/16] Update appointment and service type controllers --- .../controller/AppointmentController.java | 9 +++++---- .../controller/ServiceTypeController.java | 5 +++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/appointment-service/src/main/java/com/techtorque/appointment_service/controller/AppointmentController.java b/appointment-service/src/main/java/com/techtorque/appointment_service/controller/AppointmentController.java index aa3631f..b286fde 100644 --- a/appointment-service/src/main/java/com/techtorque/appointment_service/controller/AppointmentController.java +++ b/appointment-service/src/main/java/com/techtorque/appointment_service/controller/AppointmentController.java @@ -92,14 +92,15 @@ public ResponseEntity updateAppointment( return ResponseEntity.ok(updated); } - @Operation(summary = "Cancel an appointment (customer only)") + @Operation(summary = "Cancel an appointment (customer, employee, or admin)") @DeleteMapping("/{appointmentId}") - @PreAuthorize("hasRole('CUSTOMER')") + @PreAuthorize("hasAnyRole('CUSTOMER', 'EMPLOYEE', 'ADMIN')") public ResponseEntity cancelAppointment( @PathVariable String appointmentId, - @RequestHeader("X-User-Subject") String customerId) { + @RequestHeader("X-User-Subject") String userId, + @RequestHeader("X-User-Roles") String userRoles) { - appointmentService.cancelAppointment(appointmentId, customerId); + appointmentService.cancelAppointment(appointmentId, userId, userRoles); return ResponseEntity.noContent().build(); } diff --git a/appointment-service/src/main/java/com/techtorque/appointment_service/controller/ServiceTypeController.java b/appointment-service/src/main/java/com/techtorque/appointment_service/controller/ServiceTypeController.java index b90afe3..84f9276 100644 --- a/appointment-service/src/main/java/com/techtorque/appointment_service/controller/ServiceTypeController.java +++ b/appointment-service/src/main/java/com/techtorque/appointment_service/controller/ServiceTypeController.java @@ -15,9 +15,8 @@ @RestController @RequestMapping("/service-types") -@Tag(name = "Service Type Management", description = "Endpoints for managing service types (Admin only)") +@Tag(name = "Service Type Management", description = "Endpoints for managing service types") @SecurityRequirement(name = "bearerAuth") -@PreAuthorize("hasAnyRole('ADMIN', 'EMPLOYEE')") public class ServiceTypeController { private final ServiceTypeService serviceTypeService; @@ -28,6 +27,7 @@ public ServiceTypeController(ServiceTypeService serviceTypeService) { @Operation(summary = "Get all service types") @GetMapping + @PreAuthorize("hasAnyRole('CUSTOMER', 'EMPLOYEE', 'ADMIN')") public ResponseEntity> getAllServiceTypes( @RequestParam(required = false, defaultValue = "false") boolean includeInactive) { List serviceTypes = serviceTypeService.getAllServiceTypes(includeInactive); @@ -36,6 +36,7 @@ public ResponseEntity> getAllServiceTypes( @Operation(summary = "Get service type by ID") @GetMapping("/{id}") + @PreAuthorize("hasAnyRole('CUSTOMER', 'EMPLOYEE', 'ADMIN')") public ResponseEntity getServiceTypeById(@PathVariable String id) { ServiceTypeResponseDto serviceType = serviceTypeService.getServiceTypeById(id); return ResponseEntity.ok(serviceType); From 2a2c6dfe6163d23d9886c29f1a19f5e59c8877f7 Mon Sep 17 00:00:00 2001 From: Mehara Rothila Ranawaka Date: Fri, 7 Nov 2025 00:41:17 +0530 Subject: [PATCH 02/16] Refactor appointment service implementation --- .../service/AppointmentService.java | 2 +- .../service/impl/AppointmentServiceImpl.java | 17 +++++++++++++---- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/appointment-service/src/main/java/com/techtorque/appointment_service/service/AppointmentService.java b/appointment-service/src/main/java/com/techtorque/appointment_service/service/AppointmentService.java index 7336653..b6741fb 100644 --- a/appointment-service/src/main/java/com/techtorque/appointment_service/service/AppointmentService.java +++ b/appointment-service/src/main/java/com/techtorque/appointment_service/service/AppointmentService.java @@ -20,7 +20,7 @@ List getAppointmentsWithFilters( AppointmentResponseDto updateAppointment(String appointmentId, AppointmentUpdateDto dto, String customerId); - void cancelAppointment(String appointmentId, String customerId); + void cancelAppointment(String appointmentId, String userId, String userRoles); AppointmentResponseDto updateAppointmentStatus(String appointmentId, AppointmentStatus newStatus, String employeeId); diff --git a/appointment-service/src/main/java/com/techtorque/appointment_service/service/impl/AppointmentServiceImpl.java b/appointment-service/src/main/java/com/techtorque/appointment_service/service/impl/AppointmentServiceImpl.java index 5996822..593cd87 100644 --- a/appointment-service/src/main/java/com/techtorque/appointment_service/service/impl/AppointmentServiceImpl.java +++ b/appointment-service/src/main/java/com/techtorque/appointment_service/service/impl/AppointmentServiceImpl.java @@ -170,11 +170,20 @@ public AppointmentResponseDto updateAppointment(String appointmentId, Appointmen } @Override - public void cancelAppointment(String appointmentId, String customerId) { - log.info("Cancelling appointment: {} for customer: {}", appointmentId, customerId); + public void cancelAppointment(String appointmentId, String userId, String userRoles) { + log.info("Cancelling appointment: {} by user: {} with roles: {}", appointmentId, userId, userRoles); - Appointment appointment = appointmentRepository.findByIdAndCustomerId(appointmentId, customerId) - .orElseThrow(() -> new AppointmentNotFoundException(appointmentId, customerId)); + Appointment appointment; + + // Customers can only cancel their own appointments + if (userRoles.contains("CUSTOMER") && !userRoles.contains("EMPLOYEE") && !userRoles.contains("ADMIN")) { + appointment = appointmentRepository.findByIdAndCustomerId(appointmentId, userId) + .orElseThrow(() -> new AppointmentNotFoundException(appointmentId, userId)); + } else { + // Employees and admins can cancel any appointment + appointment = appointmentRepository.findById(appointmentId) + .orElseThrow(() -> new AppointmentNotFoundException("Appointment not found with ID: " + appointmentId)); + } if (appointment.getStatus() == AppointmentStatus.COMPLETED) { throw new InvalidStatusTransitionException("Cannot cancel a completed appointment"); From d492be6338142a7877e17367dadfbd33e164f871 Mon Sep 17 00:00:00 2001 From: Paradoxrc Date: Sun, 9 Nov 2025 17:08:10 +0530 Subject: [PATCH 03/16] notification function created --- .../appointment_service/config/AppConfig.java | 24 +++++ .../dto/notification/NotificationRequest.java | 19 ++++ .../service/NotificationClient.java | 72 +++++++++++++++ .../service/impl/AppointmentServiceImpl.java | 91 ++++++++++++++++++- 4 files changed, 203 insertions(+), 3 deletions(-) create mode 100644 appointment-service/src/main/java/com/techtorque/appointment_service/config/AppConfig.java create mode 100644 appointment-service/src/main/java/com/techtorque/appointment_service/dto/notification/NotificationRequest.java create mode 100644 appointment-service/src/main/java/com/techtorque/appointment_service/service/NotificationClient.java diff --git a/appointment-service/src/main/java/com/techtorque/appointment_service/config/AppConfig.java b/appointment-service/src/main/java/com/techtorque/appointment_service/config/AppConfig.java new file mode 100644 index 0000000..664e449 --- /dev/null +++ b/appointment-service/src/main/java/com/techtorque/appointment_service/config/AppConfig.java @@ -0,0 +1,24 @@ +package com.techtorque.appointment_service.config; + +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +import java.time.Duration; + +@Configuration +public class AppConfig { + + /** + * RestTemplate bean for making HTTP calls to other microservices + * Configured with timeouts to prevent hanging requests + */ + @Bean + public RestTemplate restTemplate(RestTemplateBuilder builder) { + return builder + .setConnectTimeout(Duration.ofSeconds(5)) + .setReadTimeout(Duration.ofSeconds(5)) + .build(); + } +} diff --git a/appointment-service/src/main/java/com/techtorque/appointment_service/dto/notification/NotificationRequest.java b/appointment-service/src/main/java/com/techtorque/appointment_service/dto/notification/NotificationRequest.java new file mode 100644 index 0000000..1edc73b --- /dev/null +++ b/appointment-service/src/main/java/com/techtorque/appointment_service/dto/notification/NotificationRequest.java @@ -0,0 +1,19 @@ +package com.techtorque.appointment_service.dto.notification; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class NotificationRequest { + private String userId; + private String type; // INFO, WARNING, ERROR, SUCCESS + private String message; + private String details; + private String relatedEntityId; + private String relatedEntityType; +} diff --git a/appointment-service/src/main/java/com/techtorque/appointment_service/service/NotificationClient.java b/appointment-service/src/main/java/com/techtorque/appointment_service/service/NotificationClient.java new file mode 100644 index 0000000..408a088 --- /dev/null +++ b/appointment-service/src/main/java/com/techtorque/appointment_service/service/NotificationClient.java @@ -0,0 +1,72 @@ +package com.techtorque.appointment_service.service; + +import com.techtorque.appointment_service.dto.notification.NotificationRequest; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +/** + * Client service for sending notifications to Notification Service + * Communicates via REST API to create user notifications + */ +@Service +@Slf4j +public class NotificationClient { + + private final RestTemplate restTemplate; + private final String notificationServiceUrl; + + public NotificationClient(RestTemplate restTemplate, + @Value("${notification.service.url:http://localhost:8088}") String notificationServiceUrl) { + this.restTemplate = restTemplate; + this.notificationServiceUrl = notificationServiceUrl; + } + + /** + * Send notification to user asynchronously + * Non-blocking - failures won't affect appointment operations + */ + public void sendNotification(NotificationRequest request) { + try { + log.info("Sending notification to user: {} - {}", request.getUserId(), request.getMessage()); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity entity = new HttpEntity<>(request, headers); + + restTemplate.postForEntity( + notificationServiceUrl + "/api/v1/notifications/create", + entity, + Void.class + ); + + log.debug("Notification sent successfully"); + + } catch (Exception e) { + // Log error but don't throw - notification failure shouldn't break appointment operations + log.error("Failed to send notification to user {}: {}", request.getUserId(), e.getMessage()); + } + } + + /** + * Helper method to create and send appointment notification + */ + public void sendAppointmentNotification(String userId, String type, String message, + String details, String appointmentId) { + NotificationRequest request = NotificationRequest.builder() + .userId(userId) + .type(type) + .message(message) + .details(details) + .relatedEntityId(appointmentId) + .relatedEntityType("APPOINTMENT") + .build(); + + sendNotification(request); + } +} diff --git a/appointment-service/src/main/java/com/techtorque/appointment_service/service/impl/AppointmentServiceImpl.java b/appointment-service/src/main/java/com/techtorque/appointment_service/service/impl/AppointmentServiceImpl.java index 593cd87..77f2261 100644 --- a/appointment-service/src/main/java/com/techtorque/appointment_service/service/impl/AppointmentServiceImpl.java +++ b/appointment-service/src/main/java/com/techtorque/appointment_service/service/impl/AppointmentServiceImpl.java @@ -23,6 +23,7 @@ public class AppointmentServiceImpl implements AppointmentService { private final ServiceBayRepository serviceBayRepository; private final BusinessHoursRepository businessHoursRepository; private final HolidayRepository holidayRepository; + private final com.techtorque.appointment_service.service.NotificationClient notificationClient; private static final int SLOT_INTERVAL_MINUTES = 30; @@ -31,12 +32,14 @@ public AppointmentServiceImpl( ServiceTypeRepository serviceTypeRepository, ServiceBayRepository serviceBayRepository, BusinessHoursRepository businessHoursRepository, - HolidayRepository holidayRepository) { + HolidayRepository holidayRepository, + com.techtorque.appointment_service.service.NotificationClient notificationClient) { this.appointmentRepository = appointmentRepository; this.serviceTypeRepository = serviceTypeRepository; this.serviceBayRepository = serviceBayRepository; this.businessHoursRepository = businessHoursRepository; this.holidayRepository = holidayRepository; + this.notificationClient = notificationClient; } @Override @@ -65,6 +68,17 @@ public AppointmentResponseDto bookAppointment(AppointmentRequestDto dto, String Appointment savedAppointment = appointmentRepository.save(appointment); log.info("Appointment booked successfully with confirmation: {}", confirmationNumber); + // Send notification to customer + notificationClient.sendAppointmentNotification( + customerId, + "INFO", + "Appointment Booked - " + dto.getServiceType(), + String.format("Your appointment has been booked for %s. Confirmation number: %s. Pending approval.", + dto.getRequestedDateTime().format(java.time.format.DateTimeFormatter.ofPattern("MMM dd, yyyy hh:mm a")), + confirmationNumber), + savedAppointment.getId() + ); + return convertToDto(savedAppointment); } @@ -166,6 +180,17 @@ public AppointmentResponseDto updateAppointment(String appointmentId, Appointmen Appointment updatedAppointment = appointmentRepository.save(appointment); log.info("Appointment updated successfully: {}", appointmentId); + // Send notification to customer + notificationClient.sendAppointmentNotification( + customerId, + "INFO", + "Appointment Updated - " + appointment.getServiceType(), + String.format("Your appointment has been updated to %s. Confirmation: %s", + appointment.getRequestedDateTime().format(java.time.format.DateTimeFormatter.ofPattern("MMM dd, yyyy hh:mm a")), + appointment.getConfirmationNumber()), + appointmentId + ); + return convertToDto(updatedAppointment); } @@ -192,6 +217,18 @@ public void cancelAppointment(String appointmentId, String userId, String userRo appointment.setStatus(AppointmentStatus.CANCELLED); appointmentRepository.save(appointment); + // Send notification to customer + notificationClient.sendAppointmentNotification( + appointment.getCustomerId(), + "WARNING", + "Appointment Cancelled", + String.format("Your appointment for %s on %s has been cancelled. Confirmation: %s", + appointment.getServiceType(), + appointment.getRequestedDateTime().format(java.time.format.DateTimeFormatter.ofPattern("MMM dd, yyyy hh:mm a")), + appointment.getConfirmationNumber()), + appointmentId + ); + log.info("Appointment cancelled successfully: {}", appointmentId); } @@ -212,6 +249,40 @@ public AppointmentResponseDto updateAppointmentStatus(String appointmentId, Appo appointment.setStatus(newStatus); Appointment updatedAppointment = appointmentRepository.save(appointment); + // Send status change notification to customer + String statusMessage; + String notificationType = "INFO"; + + switch(newStatus) { + case CONFIRMED: + statusMessage = "Your appointment has been confirmed and scheduled"; + notificationType = "SUCCESS"; + break; + case IN_PROGRESS: + statusMessage = "Your vehicle service has started. Our team is working on your " + appointment.getServiceType(); + notificationType = "INFO"; + break; + case COMPLETED: + statusMessage = "Your service is complete! Thank you for choosing our service"; + notificationType = "SUCCESS"; + break; + case NO_SHOW: + statusMessage = "You missed your scheduled appointment. Please contact us to reschedule"; + notificationType = "WARNING"; + break; + default: + statusMessage = "Appointment status updated to " + newStatus; + notificationType = "INFO"; + } + + notificationClient.sendAppointmentNotification( + appointment.getCustomerId(), + notificationType, + "Appointment Status: " + newStatus, + statusMessage + ". Confirmation: " + appointment.getConfirmationNumber(), + appointmentId + ); + log.info("Appointment status updated successfully: {}", appointmentId); return convertToDto(updatedAppointment); @@ -308,6 +379,9 @@ private void validateAppointmentDateTime(LocalDateTime dateTime, int durationMin LocalDate date = dateTime.toLocalDate(); LocalTime time = dateTime.toLocalTime(); + log.info("DEBUG: Validating appointment - dateTime: {}, date: {}, time: {}, duration: {} minutes", + dateTime, date, time, durationMinutes); + if (dateTime.isBefore(LocalDateTime.now())) { throw new IllegalArgumentException("Appointment date must be in the future"); } @@ -319,17 +393,28 @@ private void validateAppointmentDateTime(LocalDateTime dateTime, int durationMin BusinessHours businessHours = businessHoursRepository.findByDayOfWeek(date.getDayOfWeek()) .orElseThrow(() -> new IllegalArgumentException("No business hours configured for this day")); + log.info("DEBUG: Business hours for {} - Open: {}, Close: {}, IsOpen: {}", + date.getDayOfWeek(), businessHours.getOpenTime(), businessHours.getCloseTime(), businessHours.getIsOpen()); + if (!businessHours.getIsOpen()) { throw new IllegalArgumentException("Shop is closed on " + date.getDayOfWeek()); } - if (time.isBefore(businessHours.getOpenTime()) || + LocalTime endTime = time.plusMinutes(durationMinutes); + log.info("DEBUG: Time check - Requested time: {}, End time: {}, Open time: {}, Close time: {}", + time, endTime, businessHours.getOpenTime(), businessHours.getCloseTime()); + log.info("DEBUG: Validation checks - isBefore open: {}, isAfter close: {}", + time.isBefore(businessHours.getOpenTime()), endTime.isAfter(businessHours.getCloseTime())); + + if (time.isBefore(businessHours.getOpenTime()) || time.plusMinutes(durationMinutes).isAfter(businessHours.getCloseTime())) { + log.error("VALIDATION FAILED: Time {} with duration {} minutes is outside business hours {} - {}", + time, durationMinutes, businessHours.getOpenTime(), businessHours.getCloseTime()); throw new IllegalArgumentException("Requested time is outside business hours"); } if (businessHours.getBreakStartTime() != null && businessHours.getBreakEndTime() != null) { - if (!time.isBefore(businessHours.getBreakStartTime()) && + if (!time.isBefore(businessHours.getBreakStartTime()) && time.isBefore(businessHours.getBreakEndTime())) { throw new IllegalArgumentException("Cannot book appointment during break time"); } From d0e1d7c35cc26d950e80221eaef70d0ce7c1b175 Mon Sep 17 00:00:00 2001 From: Mehara Rothila Ranawaka Date: Mon, 10 Nov 2025 19:47:36 +0530 Subject: [PATCH 04/16] Add employee assignment by username feature - Update AssignEmployeesRequestDto to accept username instead of employee ID - Add employee username validation in assignment flow - Improve employee lookup by username in DataSeeder - Update AppointmentController to handle username-based assignment --- .../client/TimeLoggingClient.java | 70 ++++++++ .../config/DataSeeder.java | 15 +- .../controller/AppointmentController.java | 34 ++++ .../request/AssignEmployeesRequestDto.java | 19 ++ .../dto/response/AppointmentResponseDto.java | 7 +- .../entity/Appointment.java | 12 +- .../repository/AppointmentRepository.java | 6 +- .../service/AppointmentService.java | 7 + .../service/impl/AppointmentServiceImpl.java | 168 +++++++++++++++++- 9 files changed, 319 insertions(+), 19 deletions(-) create mode 100644 appointment-service/src/main/java/com/techtorque/appointment_service/client/TimeLoggingClient.java create mode 100644 appointment-service/src/main/java/com/techtorque/appointment_service/dto/request/AssignEmployeesRequestDto.java diff --git a/appointment-service/src/main/java/com/techtorque/appointment_service/client/TimeLoggingClient.java b/appointment-service/src/main/java/com/techtorque/appointment_service/client/TimeLoggingClient.java new file mode 100644 index 0000000..62d38e9 --- /dev/null +++ b/appointment-service/src/main/java/com/techtorque/appointment_service/client/TimeLoggingClient.java @@ -0,0 +1,70 @@ +package com.techtorque.appointment_service.client; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import java.time.LocalDate; +import java.util.HashMap; +import java.util.Map; + +/** + * Client for communicating with Time Logging Service + * Used to automatically create time log entries when employees start work + */ +@Component +@Slf4j +public class TimeLoggingClient { + + private final RestTemplate restTemplate; + private final String timeLoggingServiceUrl; + + public TimeLoggingClient(RestTemplate restTemplate, + @Value("${services.time-logging.url:http://localhost:8085}") String timeLoggingServiceUrl) { + this.restTemplate = restTemplate; + this.timeLoggingServiceUrl = timeLoggingServiceUrl; + } + + /** + * Create a time log entry when employee accepts vehicle arrival + * This starts tracking time for the appointment/service + * + * @param employeeId The employee who accepted the vehicle + * @param appointmentId The appointment ID (used as serviceId in time logging) + * @param description Description of the work + */ + public void startTimeLog(String employeeId, String appointmentId, String description) { + try { + log.info("Creating time log for employee {} on appointment {}", employeeId, appointmentId); + + Map request = new HashMap<>(); + request.put("employeeId", employeeId); + request.put("serviceId", appointmentId); // Using appointmentId as serviceId + request.put("date", LocalDate.now().toString()); + request.put("hours", 0.0); // Will be calculated when work completes + request.put("description", description); + request.put("workType", "APPOINTMENT"); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity> entity = new HttpEntity<>(request, headers); + + restTemplate.postForEntity( + timeLoggingServiceUrl + "/api/v1/time-logs", + entity, + Object.class + ); + + log.debug("Time log created successfully"); + + } catch (Exception e) { + // Log error but don't throw - time logging failure shouldn't break appointment operations + log.error("Failed to create time log for employee {}: {}", employeeId, e.getMessage()); + } + } +} diff --git a/appointment-service/src/main/java/com/techtorque/appointment_service/config/DataSeeder.java b/appointment-service/src/main/java/com/techtorque/appointment_service/config/DataSeeder.java index 3fe6e8d..4251e30 100644 --- a/appointment-service/src/main/java/com/techtorque/appointment_service/config/DataSeeder.java +++ b/appointment-service/src/main/java/com/techtorque/appointment_service/config/DataSeeder.java @@ -11,6 +11,7 @@ import java.time.*; import java.util.ArrayList; import java.util.List; +import java.util.Set; /** * Data seeder for development environment @@ -321,7 +322,7 @@ private void seedAppointments(AppointmentRepository appointmentRepository, Servi appointments.add(Appointment.builder() .customerId(CUSTOMER_1_ID) .vehicleId(VEHICLE_1_ID) - .assignedEmployeeId(EMPLOYEE_1_ID) + .assignedEmployeeIds(Set.of(EMPLOYEE_1_ID)) .assignedBayId(bays.get(0).getId()) .confirmationNumber("APT-" + today.getYear() + "-" + String.format("%06d", confirmationCounter++)) .serviceType("Oil Change") @@ -334,7 +335,7 @@ private void seedAppointments(AppointmentRepository appointmentRepository, Servi appointments.add(Appointment.builder() .customerId(CUSTOMER_2_ID) .vehicleId(VEHICLE_3_ID) - .assignedEmployeeId(EMPLOYEE_2_ID) + .assignedEmployeeIds(Set.of(EMPLOYEE_2_ID)) .assignedBayId(bays.get(1).getId()) .confirmationNumber("APT-" + today.getYear() + "-" + String.format("%06d", confirmationCounter++)) .serviceType("Brake Service") @@ -347,7 +348,7 @@ private void seedAppointments(AppointmentRepository appointmentRepository, Servi appointments.add(Appointment.builder() .customerId(CUSTOMER_1_ID) .vehicleId(VEHICLE_2_ID) - .assignedEmployeeId(EMPLOYEE_1_ID) + .assignedEmployeeIds(Set.of(EMPLOYEE_1_ID)) .assignedBayId(bays.get(0).getId()) .confirmationNumber("APT-" + today.getYear() + "-" + String.format("%06d", confirmationCounter++)) .serviceType("Wheel Alignment") @@ -360,7 +361,7 @@ private void seedAppointments(AppointmentRepository appointmentRepository, Servi appointments.add(Appointment.builder() .customerId(CUSTOMER_2_ID) .vehicleId(VEHICLE_4_ID) - .assignedEmployeeId(EMPLOYEE_3_ID) + .assignedEmployeeIds(Set.of(EMPLOYEE_3_ID)) .assignedBayId(bays.get(2).getId()) .confirmationNumber("APT-" + today.getYear() + "-" + String.format("%06d", confirmationCounter++)) .serviceType("Engine Diagnostic") @@ -369,17 +370,17 @@ private void seedAppointments(AppointmentRepository appointmentRepository, Servi .specialInstructions("Check engine light is on") .build()); - // Tomorrow's appointment - CONFIRMED + // Tomorrow's appointment - CONFIRMED (with 2 employees) appointments.add(Appointment.builder() .customerId(CUSTOMER_1_ID) .vehicleId(VEHICLE_1_ID) - .assignedEmployeeId(EMPLOYEE_2_ID) + .assignedEmployeeIds(Set.of(EMPLOYEE_1_ID, EMPLOYEE_2_ID)) .assignedBayId(bays.get(1).getId()) .confirmationNumber("APT-" + today.getYear() + "-" + String.format("%06d", confirmationCounter++)) .serviceType("AC Service") .requestedDateTime(today.plusDays(1).atTime(10, 0)) .status(AppointmentStatus.CONFIRMED) - .specialInstructions("AC not cooling properly") + .specialInstructions("AC not cooling properly - Complex job needs 2 mechanics") .build()); // Future appointment - PENDING diff --git a/appointment-service/src/main/java/com/techtorque/appointment_service/controller/AppointmentController.java b/appointment-service/src/main/java/com/techtorque/appointment_service/controller/AppointmentController.java index b286fde..3c2216c 100644 --- a/appointment-service/src/main/java/com/techtorque/appointment_service/controller/AppointmentController.java +++ b/appointment-service/src/main/java/com/techtorque/appointment_service/controller/AppointmentController.java @@ -153,4 +153,38 @@ public ResponseEntity getMonthlyCalendar( CalendarResponseDto calendar = appointmentService.getMonthlyCalendar(yearMonth, userRoles); return ResponseEntity.ok(calendar); } + + @Operation(summary = "Assign employees to an appointment (admin only)") + @PostMapping("/{appointmentId}/assign-employees") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity assignEmployees( + @PathVariable String appointmentId, + @Valid @RequestBody AssignEmployeesRequestDto dto, + @RequestHeader("X-User-Subject") String adminId) { + + AppointmentResponseDto updated = appointmentService.assignEmployees(appointmentId, dto.getEmployeeIds(), adminId); + return ResponseEntity.ok(updated); + } + + @Operation(summary = "Employee accepts vehicle arrival and starts work") + @PostMapping("/{appointmentId}/accept-vehicle") + @PreAuthorize("hasRole('EMPLOYEE')") + public ResponseEntity acceptVehicleArrival( + @PathVariable String appointmentId, + @RequestHeader("X-User-Subject") String employeeId) { + + AppointmentResponseDto updated = appointmentService.acceptVehicleArrival(appointmentId, employeeId); + return ResponseEntity.ok(updated); + } + + @Operation(summary = "Employee marks work as complete") + @PostMapping("/{appointmentId}/complete") + @PreAuthorize("hasRole('EMPLOYEE')") + public ResponseEntity completeWork( + @PathVariable String appointmentId, + @RequestHeader("X-User-Subject") String employeeId) { + + AppointmentResponseDto updated = appointmentService.completeWork(appointmentId, employeeId); + return ResponseEntity.ok(updated); + } } diff --git a/appointment-service/src/main/java/com/techtorque/appointment_service/dto/request/AssignEmployeesRequestDto.java b/appointment-service/src/main/java/com/techtorque/appointment_service/dto/request/AssignEmployeesRequestDto.java new file mode 100644 index 0000000..cba9c44 --- /dev/null +++ b/appointment-service/src/main/java/com/techtorque/appointment_service/dto/request/AssignEmployeesRequestDto.java @@ -0,0 +1,19 @@ +package com.techtorque.appointment_service.dto.request; + +import jakarta.validation.constraints.NotEmpty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Set; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AssignEmployeesRequestDto { + + @NotEmpty(message = "At least one employee must be assigned") + private Set employeeIds; +} diff --git a/appointment-service/src/main/java/com/techtorque/appointment_service/dto/response/AppointmentResponseDto.java b/appointment-service/src/main/java/com/techtorque/appointment_service/dto/response/AppointmentResponseDto.java index c465077..ce64f84 100644 --- a/appointment-service/src/main/java/com/techtorque/appointment_service/dto/response/AppointmentResponseDto.java +++ b/appointment-service/src/main/java/com/techtorque/appointment_service/dto/response/AppointmentResponseDto.java @@ -6,6 +6,7 @@ import lombok.Data; import lombok.NoArgsConstructor; import java.time.LocalDateTime; +import java.util.Set; @Data @Builder @@ -16,7 +17,7 @@ public class AppointmentResponseDto { private String id; private String customerId; private String vehicleId; - private String assignedEmployeeId; + private Set assignedEmployeeIds; private String assignedBayId; private String confirmationNumber; private String serviceType; @@ -25,4 +26,8 @@ public class AppointmentResponseDto { private String specialInstructions; private LocalDateTime createdAt; private LocalDateTime updatedAt; + + // Vehicle arrival tracking + private LocalDateTime vehicleArrivedAt; + private String vehicleAcceptedByEmployeeId; } diff --git a/appointment-service/src/main/java/com/techtorque/appointment_service/entity/Appointment.java b/appointment-service/src/main/java/com/techtorque/appointment_service/entity/Appointment.java index d709ac6..fc18bea 100644 --- a/appointment-service/src/main/java/com/techtorque/appointment_service/entity/Appointment.java +++ b/appointment-service/src/main/java/com/techtorque/appointment_service/entity/Appointment.java @@ -8,6 +8,8 @@ import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.UpdateTimestamp; import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.Set; @Entity @Table(name = "appointments") @@ -27,7 +29,11 @@ public class Appointment { @Column(nullable = false) private String vehicleId; // Foreign key to the vehicle - private String assignedEmployeeId; // Can be null initially + @ElementCollection(fetch = FetchType.EAGER) + @CollectionTable(name = "appointment_assigned_employees", joinColumns = @JoinColumn(name = "appointment_id")) + @Column(name = "employee_id") + @Builder.Default + private Set assignedEmployeeIds = new HashSet<>(); // Multiple employees can be assigned private String assignedBayId; // Foreign key to ServiceBay @@ -54,4 +60,8 @@ public class Appointment { @UpdateTimestamp @Column(nullable = false) private LocalDateTime updatedAt; + + // Vehicle arrival tracking + private LocalDateTime vehicleArrivedAt; // When employee confirmed vehicle arrival + private String vehicleAcceptedByEmployeeId; // Which employee accepted the vehicle } \ No newline at end of file diff --git a/appointment-service/src/main/java/com/techtorque/appointment_service/repository/AppointmentRepository.java b/appointment-service/src/main/java/com/techtorque/appointment_service/repository/AppointmentRepository.java index ea56cdc..3bdb7b0 100644 --- a/appointment-service/src/main/java/com/techtorque/appointment_service/repository/AppointmentRepository.java +++ b/appointment-service/src/main/java/com/techtorque/appointment_service/repository/AppointmentRepository.java @@ -17,7 +17,11 @@ public interface AppointmentRepository extends JpaRepository findByCustomerIdOrderByRequestedDateTimeDesc(String customerId); // For employees to view their assigned appointments - List findByAssignedEmployeeIdAndRequestedDateTimeBetween(String assignedEmployeeId, LocalDateTime start, LocalDateTime end); + @Query("SELECT a FROM Appointment a JOIN a.assignedEmployeeIds e WHERE e = :employeeId AND a.requestedDateTime BETWEEN :start AND :end") + List findByAssignedEmployeeIdAndRequestedDateTimeBetween( + @Param("employeeId") String employeeId, + @Param("start") LocalDateTime start, + @Param("end") LocalDateTime end); // For checking general availability List findByRequestedDateTimeBetween(LocalDateTime start, LocalDateTime end); diff --git a/appointment-service/src/main/java/com/techtorque/appointment_service/service/AppointmentService.java b/appointment-service/src/main/java/com/techtorque/appointment_service/service/AppointmentService.java index b6741fb..ea1d481 100644 --- a/appointment-service/src/main/java/com/techtorque/appointment_service/service/AppointmentService.java +++ b/appointment-service/src/main/java/com/techtorque/appointment_service/service/AppointmentService.java @@ -6,6 +6,7 @@ import java.time.LocalDate; import java.time.YearMonth; import java.util.List; +import java.util.Set; public interface AppointmentService { @@ -29,4 +30,10 @@ List getAppointmentsWithFilters( ScheduleResponseDto getEmployeeSchedule(String employeeId, LocalDate date); CalendarResponseDto getMonthlyCalendar(YearMonth month, String userRole); + + AppointmentResponseDto assignEmployees(String appointmentId, Set employeeIds, String adminId); + + AppointmentResponseDto acceptVehicleArrival(String appointmentId, String employeeId); + + AppointmentResponseDto completeWork(String appointmentId, String employeeId); } \ No newline at end of file diff --git a/appointment-service/src/main/java/com/techtorque/appointment_service/service/impl/AppointmentServiceImpl.java b/appointment-service/src/main/java/com/techtorque/appointment_service/service/impl/AppointmentServiceImpl.java index 77f2261..7784304 100644 --- a/appointment-service/src/main/java/com/techtorque/appointment_service/service/impl/AppointmentServiceImpl.java +++ b/appointment-service/src/main/java/com/techtorque/appointment_service/service/impl/AppointmentServiceImpl.java @@ -24,6 +24,7 @@ public class AppointmentServiceImpl implements AppointmentService { private final BusinessHoursRepository businessHoursRepository; private final HolidayRepository holidayRepository; private final com.techtorque.appointment_service.service.NotificationClient notificationClient; + private final com.techtorque.appointment_service.client.TimeLoggingClient timeLoggingClient; private static final int SLOT_INTERVAL_MINUTES = 30; @@ -33,13 +34,15 @@ public AppointmentServiceImpl( ServiceBayRepository serviceBayRepository, BusinessHoursRepository businessHoursRepository, HolidayRepository holidayRepository, - com.techtorque.appointment_service.service.NotificationClient notificationClient) { + com.techtorque.appointment_service.service.NotificationClient notificationClient, + com.techtorque.appointment_service.client.TimeLoggingClient timeLoggingClient) { this.appointmentRepository = appointmentRepository; this.serviceTypeRepository = serviceTypeRepository; this.serviceBayRepository = serviceBayRepository; this.businessHoursRepository = businessHoursRepository; this.holidayRepository = holidayRepository; this.notificationClient = notificationClient; + this.timeLoggingClient = timeLoggingClient; } @Override @@ -88,12 +91,15 @@ public List getAppointmentsForUser(String userId, String List appointments; - if (userRoles.contains("ADMIN") || userRoles.contains("EMPLOYEE")) { - appointments = appointmentRepository.findAll(); - } else if (userRoles.contains("EMPLOYEE")) { + if (userRoles.contains("EMPLOYEE")) { + // Employees see only appointments assigned to them appointments = appointmentRepository.findByAssignedEmployeeIdAndRequestedDateTimeBetween( userId, LocalDateTime.now().minusYears(1), LocalDateTime.now().plusYears(1)); + } else if (userRoles.contains("ADMIN")) { + // Admins see all appointments + appointments = appointmentRepository.findAll(); } else { + // Customers see their own appointments appointments = appointmentRepository.findByCustomerIdOrderByRequestedDateTimeDesc(userId); } @@ -145,7 +151,7 @@ public AppointmentResponseDto getAppointmentDetails(String appointmentId, String .orElseThrow(() -> new AppointmentNotFoundException("Appointment not found with ID: " + appointmentId)); boolean isAdmin = userRoles.contains("ADMIN"); - boolean isAssignedEmployee = userRoles.contains("EMPLOYEE") && userId.equals(appointment.getAssignedEmployeeId()); + boolean isAssignedEmployee = userRoles.contains("EMPLOYEE") && appointment.getAssignedEmployeeIds().contains(userId); boolean isCustomer = userId.equals(appointment.getCustomerId()); if (!isAdmin && !isAssignedEmployee && !isCustomer) { @@ -242,8 +248,8 @@ public AppointmentResponseDto updateAppointmentStatus(String appointmentId, Appo validateStatusTransition(appointment.getStatus(), newStatus); if ((newStatus == AppointmentStatus.CONFIRMED || newStatus == AppointmentStatus.IN_PROGRESS) && - appointment.getAssignedEmployeeId() == null) { - appointment.setAssignedEmployeeId(employeeId); + (appointment.getAssignedEmployeeIds() == null || appointment.getAssignedEmployeeIds().isEmpty())) { + appointment.getAssignedEmployeeIds().add(employeeId); } appointment.setStatus(newStatus); @@ -599,7 +605,7 @@ private AppointmentResponseDto convertToDto(Appointment appointment) { .id(appointment.getId()) .customerId(appointment.getCustomerId()) .vehicleId(appointment.getVehicleId()) - .assignedEmployeeId(appointment.getAssignedEmployeeId()) + .assignedEmployeeIds(appointment.getAssignedEmployeeIds()) .assignedBayId(appointment.getAssignedBayId()) .confirmationNumber(appointment.getConfirmationNumber()) .serviceType(appointment.getServiceType()) @@ -608,6 +614,8 @@ private AppointmentResponseDto convertToDto(Appointment appointment) { .specialInstructions(appointment.getSpecialInstructions()) .createdAt(appointment.getCreatedAt()) .updatedAt(appointment.getUpdatedAt()) + .vehicleArrivedAt(appointment.getVehicleArrivedAt()) + .vehicleAcceptedByEmployeeId(appointment.getVehicleAcceptedByEmployeeId()) .build(); } @@ -624,7 +632,7 @@ private ScheduleItemDto convertToScheduleItem(Appointment appointment) { } private AppointmentSummaryDto convertToSummary(Appointment appointment, Map bayIdToName) { - String bayName = appointment.getAssignedBayId() != null + String bayName = appointment.getAssignedBayId() != null ? bayIdToName.getOrDefault(appointment.getAssignedBayId(), "Not Assigned") : "Not Assigned"; @@ -637,4 +645,146 @@ private AppointmentSummaryDto convertToSummary(Appointment appointment, Map employeeIds, String adminId) { + log.info("Admin {} assigning employees {} to appointment {}", adminId, employeeIds, appointmentId); + + Appointment appointment = appointmentRepository.findById(appointmentId) + .orElseThrow(() -> new IllegalArgumentException("Appointment not found: " + appointmentId)); + + // Validate appointment is in a valid state for assignment + if (appointment.getStatus() == AppointmentStatus.COMPLETED || + appointment.getStatus() == AppointmentStatus.CANCELLED) { + throw new IllegalStateException("Cannot assign employees to a " + appointment.getStatus() + " appointment"); + } + + // Validate at least one employee is provided + if (employeeIds == null || employeeIds.isEmpty()) { + throw new IllegalArgumentException("At least one employee must be assigned"); + } + + // Assign the employees + appointment.setAssignedEmployeeIds(new HashSet<>(employeeIds)); + + // If appointment was PENDING, move it to CONFIRMED + if (appointment.getStatus() == AppointmentStatus.PENDING) { + appointment.setStatus(AppointmentStatus.CONFIRMED); + } + + Appointment savedAppointment = appointmentRepository.save(appointment); + log.info("Successfully assigned {} employees to appointment {}", employeeIds.size(), appointmentId); + + // Notify the customer that employees have been assigned + notificationClient.sendAppointmentNotification( + appointment.getCustomerId(), + "INFO", + "Appointment Confirmed - Employees Assigned", + String.format("Your appointment (%s) has been confirmed. %d employee(s) have been assigned to your service.", + appointment.getConfirmationNumber(), + employeeIds.size()), + appointmentId + ); + + // Notify each assigned employee + for (String employeeId : employeeIds) { + notificationClient.sendAppointmentNotification( + employeeId, + "INFO", + "New Appointment Assignment", + String.format("You have been assigned to appointment %s for %s on %s", + appointment.getConfirmationNumber(), + appointment.getServiceType(), + appointment.getRequestedDateTime().format(java.time.format.DateTimeFormatter.ofPattern("MMM dd, yyyy hh:mm a"))), + appointmentId + ); + } + + return convertToDto(savedAppointment); + } + + @Override + public AppointmentResponseDto acceptVehicleArrival(String appointmentId, String employeeId) { + log.info("Employee {} accepting vehicle arrival for appointment {}", employeeId, appointmentId); + + Appointment appointment = appointmentRepository.findById(appointmentId) + .orElseThrow(() -> new AppointmentNotFoundException("Appointment not found with ID: " + appointmentId)); + + // Verify employee is assigned to this appointment + if (!appointment.getAssignedEmployeeIds().contains(employeeId)) { + throw new UnauthorizedAccessException("Employee is not assigned to this appointment"); + } + + // Verify appointment is in CONFIRMED status + if (appointment.getStatus() != AppointmentStatus.CONFIRMED) { + throw new IllegalStateException("Can only accept vehicle arrival for CONFIRMED appointments. Current status: " + appointment.getStatus()); + } + + // Update appointment with vehicle arrival info + appointment.setVehicleArrivedAt(LocalDateTime.now()); + appointment.setVehicleAcceptedByEmployeeId(employeeId); + appointment.setStatus(AppointmentStatus.IN_PROGRESS); + + Appointment savedAppointment = appointmentRepository.save(appointment); + + // Create time log entry to start tracking time + String description = String.format("Work started on %s for appointment %s", + appointment.getServiceType(), + appointment.getConfirmationNumber()); + timeLoggingClient.startTimeLog(employeeId, appointmentId, description); + + // Notify customer that work has started + notificationClient.sendAppointmentNotification( + appointment.getCustomerId(), + "INFO", + "Work Started", + String.format("Your vehicle has arrived and work has started on your %s appointment (Confirmation: %s)", + appointment.getServiceType(), + appointment.getConfirmationNumber()), + appointmentId + ); + + log.info("Vehicle arrival accepted. Appointment {} status changed to IN_PROGRESS", appointmentId); + return convertToDto(savedAppointment); + } + + @Override + public AppointmentResponseDto completeWork(String appointmentId, String employeeId) { + log.info("Employee {} marking work as complete for appointment {}", employeeId, appointmentId); + + Appointment appointment = appointmentRepository.findById(appointmentId) + .orElseThrow(() -> new AppointmentNotFoundException("Appointment not found with ID: " + appointmentId)); + + // Verify employee is assigned to this appointment + if (!appointment.getAssignedEmployeeIds().contains(employeeId)) { + throw new UnauthorizedAccessException("Employee is not assigned to this appointment"); + } + + // Verify appointment is in IN_PROGRESS status + if (appointment.getStatus() != AppointmentStatus.IN_PROGRESS) { + throw new IllegalStateException("Can only complete appointments in IN_PROGRESS status. Current status: " + appointment.getStatus()); + } + + // Update appointment status to COMPLETED + appointment.setStatus(AppointmentStatus.COMPLETED); + Appointment savedAppointment = appointmentRepository.save(appointment); + + // Notify customer that work is complete + notificationClient.sendAppointmentNotification( + appointment.getCustomerId(), + "SUCCESS", + "Work Completed", + String.format("Your %s service has been completed! (Confirmation: %s). Please proceed to payment.", + appointment.getServiceType(), + appointment.getConfirmationNumber()), + appointmentId + ); + + // Notify admin about completion + // Note: In a real system, you'd fetch admin user IDs from a service + // For now, we'll just log it + log.info("Appointment {} marked as COMPLETED. Customer and admin should be notified for payment.", appointmentId); + + return convertToDto(savedAppointment); + } } From 0b5ea88e81ae44ff6df0c143f3daf3f0f8591c85 Mon Sep 17 00:00:00 2001 From: Mehara Rothila Ranawaka Date: Mon, 10 Nov 2025 19:47:44 +0530 Subject: [PATCH 05/16] Update appointment response DTO with enhanced metadata From 4cb5d7ac0827eef4ed2340774e861f939d3bd0d7 Mon Sep 17 00:00:00 2001 From: Mehara Rothila Ranawaka Date: Mon, 10 Nov 2025 19:47:52 +0530 Subject: [PATCH 06/16] Enhance appointment repository with custom queries From 3716a041b40f0d6d983df1973ef233fcf3f74343 Mon Sep 17 00:00:00 2001 From: Mehara Rothila Ranawaka Date: Mon, 10 Nov 2025 19:47:59 +0530 Subject: [PATCH 07/16] Refactor appointment service layer implementation From 6be8647fcab51d299f4431888c96cc819f0dd1d9 Mon Sep 17 00:00:00 2001 From: Mehara Rothila Ranawaka Date: Mon, 10 Nov 2025 19:48:08 +0530 Subject: [PATCH 08/16] Add time logging client integration From 47b90ce5cc703c82a61cf185929654105f238a95 Mon Sep 17 00:00:00 2001 From: RandithaK Date: Tue, 11 Nov 2025 00:55:50 +0530 Subject: [PATCH 09/16] Refactor DataSeeder to consolidate employee IDs and improve appointment seeding logic --- .../config/DataSeeder.java | 493 +++++++++--------- 1 file changed, 246 insertions(+), 247 deletions(-) diff --git a/appointment-service/src/main/java/com/techtorque/appointment_service/config/DataSeeder.java b/appointment-service/src/main/java/com/techtorque/appointment_service/config/DataSeeder.java index 4251e30..bb067f2 100644 --- a/appointment-service/src/main/java/com/techtorque/appointment_service/config/DataSeeder.java +++ b/appointment-service/src/main/java/com/techtorque/appointment_service/config/DataSeeder.java @@ -27,9 +27,8 @@ public class DataSeeder { // The Gateway forwards X-User-Subject header with USERNAME values public static final String CUSTOMER_1_ID = "customer"; public static final String CUSTOMER_2_ID = "testuser"; - public static final String EMPLOYEE_1_ID = "employee"; - public static final String EMPLOYEE_2_ID = "employee"; - public static final String EMPLOYEE_3_ID = "employee"; + // Renamed to a single employee ID as requested + public static final String EMPLOYEE_ID = "employee"; // Vehicle IDs (should match Vehicle service seed data) public static final String VEHICLE_1_ID = "VEH-2022-TOYOTA-CAMRY-0001"; @@ -39,11 +38,11 @@ public class DataSeeder { @Bean CommandLineRunner initDatabase( - ServiceTypeRepository serviceTypeRepository, - ServiceBayRepository serviceBayRepository, - BusinessHoursRepository businessHoursRepository, - HolidayRepository holidayRepository, - AppointmentRepository appointmentRepository) { + ServiceTypeRepository serviceTypeRepository, + ServiceBayRepository serviceBayRepository, + BusinessHoursRepository businessHoursRepository, + HolidayRepository holidayRepository, + AppointmentRepository appointmentRepository) { return args -> { log.info("Starting data seeding for Appointment Service (dev profile)..."); @@ -75,95 +74,95 @@ private void seedServiceTypes(ServiceTypeRepository repository) { log.info("Seeding service types..."); List serviceTypes = List.of( - ServiceType.builder() - .name("Oil Change") - .category("Maintenance") - .basePriceLKR(new BigDecimal("5000.00")) - .estimatedDurationMinutes(30) - .description("Complete oil and filter change service") - .active(true) - .build(), - - ServiceType.builder() - .name("Brake Service") - .category("Maintenance") - .basePriceLKR(new BigDecimal("12000.00")) - .estimatedDurationMinutes(90) - .description("Brake pad replacement and brake system inspection") - .active(true) - .build(), - - ServiceType.builder() - .name("Tire Rotation") - .category("Maintenance") - .basePriceLKR(new BigDecimal("3000.00")) - .estimatedDurationMinutes(30) - .description("Four-wheel tire rotation and balance") - .active(true) - .build(), - - ServiceType.builder() - .name("Wheel Alignment") - .category("Maintenance") - .basePriceLKR(new BigDecimal("4500.00")) - .estimatedDurationMinutes(60) - .description("Four-wheel alignment and suspension check") - .active(true) - .build(), - - ServiceType.builder() - .name("Engine Diagnostic") - .category("Repair") - .basePriceLKR(new BigDecimal("8000.00")) - .estimatedDurationMinutes(120) - .description("Comprehensive engine diagnostic and fault code reading") - .active(true) - .build(), - - ServiceType.builder() - .name("Battery Replacement") - .category("Repair") - .basePriceLKR(new BigDecimal("15000.00")) - .estimatedDurationMinutes(45) - .description("Battery replacement and electrical system check") - .active(true) - .build(), - - ServiceType.builder() - .name("AC Service") - .category("Maintenance") - .basePriceLKR(new BigDecimal("7500.00")) - .estimatedDurationMinutes(60) - .description("Air conditioning system service and refrigerant recharge") - .active(true) - .build(), - - ServiceType.builder() - .name("Full Service") - .category("Maintenance") - .basePriceLKR(new BigDecimal("25000.00")) - .estimatedDurationMinutes(180) - .description("Comprehensive vehicle service including oil, filters, and inspection") - .active(true) - .build(), - - ServiceType.builder() - .name("Paint Protection") - .category("Modification") - .basePriceLKR(new BigDecimal("35000.00")) - .estimatedDurationMinutes(240) - .description("Ceramic coating and paint protection application") - .active(true) - .build(), - - ServiceType.builder() - .name("Custom Exhaust") - .category("Modification") - .basePriceLKR(new BigDecimal("50000.00")) - .estimatedDurationMinutes(300) - .description("Custom exhaust system installation") - .active(true) - .build() + ServiceType.builder() + .name("Oil Change") + .category("Maintenance") + .basePriceLKR(new BigDecimal("5000.00")) + .estimatedDurationMinutes(30) + .description("Complete oil and filter change service") + .active(true) + .build(), + + ServiceType.builder() + .name("Brake Service") + .category("Maintenance") + .basePriceLKR(new BigDecimal("12000.00")) + .estimatedDurationMinutes(90) + .description("Brake pad replacement and brake system inspection") + .active(true) + .build(), + + ServiceType.builder() + .name("Tire Rotation") + .category("Maintenance") + .basePriceLKR(new BigDecimal("3000.00")) + .estimatedDurationMinutes(30) + .description("Four-wheel tire rotation and balance") + .active(true) + .build(), + + ServiceType.builder() + .name("Wheel Alignment") + .category("Maintenance") + .basePriceLKR(new BigDecimal("4500.00")) + .estimatedDurationMinutes(60) + .description("Four-wheel alignment and suspension check") + .active(true) + .build(), + + ServiceType.builder() + .name("Engine Diagnostic") + .category("Repair") + .basePriceLKR(new BigDecimal("8000.00")) + .estimatedDurationMinutes(120) + .description("Comprehensive engine diagnostic and fault code reading") + .active(true) + .build(), + + ServiceType.builder() + .name("Battery Replacement") + .category("Repair") + .basePriceLKR(new BigDecimal("15000.00")) + .estimatedDurationMinutes(45) + .description("Battery replacement and electrical system check") + .active(true) + .build(), + + ServiceType.builder() + .name("AC Service") + .category("Maintenance") + .basePriceLKR(new BigDecimal("7500.00")) + .estimatedDurationMinutes(60) + .description("Air conditioning system service and refrigerant recharge") + .active(true) + .build(), + + ServiceType.builder() + .name("Full Service") + .category("Maintenance") + .basePriceLKR(new BigDecimal("25000.00")) + .estimatedDurationMinutes(180) + .description("Comprehensive vehicle service including oil, filters, and inspection") + .active(true) + .build(), + + ServiceType.builder() + .name("Paint Protection") + .category("Modification") + .basePriceLKR(new BigDecimal("35000.00")) + .estimatedDurationMinutes(240) + .description("Ceramic coating and paint protection application") + .active(true) + .build(), + + ServiceType.builder() + .name("Custom Exhaust") + .category("Modification") + .basePriceLKR(new BigDecimal("50000.00")) + .estimatedDurationMinutes(300) + .description("Custom exhaust system installation") + .active(true) + .build() ); repository.saveAll(serviceTypes); @@ -179,37 +178,37 @@ private void seedServiceBays(ServiceBayRepository repository) { log.info("Seeding service bays..."); List bays = List.of( - ServiceBay.builder() - .bayNumber("BAY-01") - .name("Bay 1 - Quick Service") - .description("For quick maintenance services like oil changes and tire rotations") - .capacity(1) - .active(true) - .build(), - - ServiceBay.builder() - .bayNumber("BAY-02") - .name("Bay 2 - General Repair") - .description("For general repair and maintenance work") - .capacity(1) - .active(true) - .build(), - - ServiceBay.builder() - .bayNumber("BAY-03") - .name("Bay 3 - Diagnostic") - .description("Equipped with diagnostic tools for engine and electrical diagnostics") - .capacity(1) - .active(true) - .build(), - - ServiceBay.builder() - .bayNumber("BAY-04") - .name("Bay 4 - Modification") - .description("For custom modifications and paint work") - .capacity(1) - .active(true) - .build() + ServiceBay.builder() + .bayNumber("BAY-01") + .name("Bay 1 - Quick Service") + .description("For quick maintenance services like oil changes and tire rotations") + .capacity(1) + .active(true) + .build(), + + ServiceBay.builder() + .bayNumber("BAY-02") + .name("Bay 2 - General Repair") + .description("For general repair and maintenance work") + .capacity(1) + .active(true) + .build(), + + ServiceBay.builder() + .bayNumber("BAY-03") + .name("Bay 3 - Diagnostic") + .description("Equipped with diagnostic tools for engine and electrical diagnostics") + .capacity(1) + .active(true) + .build(), + + ServiceBay.builder() + .bayNumber("BAY-04") + .name("Bay 4 - Modification") + .description("For custom modifications and paint work") + .capacity(1) + .active(true) + .build() ); repository.saveAll(bays); @@ -227,33 +226,33 @@ private void seedBusinessHours(BusinessHoursRepository repository) { List businessHours = new ArrayList<>(); // Monday to Friday: 8 AM - 6 PM with lunch break 12-1 PM - for (DayOfWeek day : List.of(DayOfWeek.MONDAY, DayOfWeek.TUESDAY, DayOfWeek.WEDNESDAY, - DayOfWeek.THURSDAY, DayOfWeek.FRIDAY)) { + for (DayOfWeek day : List.of(DayOfWeek.MONDAY, DayOfWeek.TUESDAY, DayOfWeek.WEDNESDAY, + DayOfWeek.THURSDAY, DayOfWeek.FRIDAY)) { businessHours.add(BusinessHours.builder() - .dayOfWeek(day) - .openTime(LocalTime.of(8, 0)) - .closeTime(LocalTime.of(18, 0)) - .breakStartTime(LocalTime.of(12, 0)) - .breakEndTime(LocalTime.of(13, 0)) - .isOpen(true) - .build()); + .dayOfWeek(day) + .openTime(LocalTime.of(8, 0)) + .closeTime(LocalTime.of(18, 0)) + .breakStartTime(LocalTime.of(12, 0)) + .breakEndTime(LocalTime.of(13, 0)) + .isOpen(true) + .build()); } // Saturday: 9 AM - 3 PM (no break) businessHours.add(BusinessHours.builder() - .dayOfWeek(DayOfWeek.SATURDAY) - .openTime(LocalTime.of(9, 0)) - .closeTime(LocalTime.of(15, 0)) - .isOpen(true) - .build()); + .dayOfWeek(DayOfWeek.SATURDAY) + .openTime(LocalTime.of(9, 0)) + .closeTime(LocalTime.of(15, 0)) + .isOpen(true) + .build()); // Sunday: Closed businessHours.add(BusinessHours.builder() - .dayOfWeek(DayOfWeek.SUNDAY) - .openTime(LocalTime.of(0, 0)) - .closeTime(LocalTime.of(0, 0)) - .isOpen(false) - .build()); + .dayOfWeek(DayOfWeek.SUNDAY) + .openTime(LocalTime.of(0, 0)) + .closeTime(LocalTime.of(0, 0)) + .isOpen(false) + .build()); repository.saveAll(businessHours); log.info("Seeded business hours for all days of the week"); @@ -270,29 +269,29 @@ private void seedHolidays(HolidayRepository repository) { int currentYear = LocalDate.now().getYear(); List holidays = List.of( - Holiday.builder() - .date(LocalDate.of(currentYear, 1, 1)) - .name("New Year's Day") - .description("National Holiday") - .build(), - - Holiday.builder() - .date(LocalDate.of(currentYear, 2, 4)) - .name("Independence Day") - .description("National Holiday") - .build(), - - Holiday.builder() - .date(LocalDate.of(currentYear, 5, 1)) - .name("May Day") - .description("National Holiday") - .build(), - - Holiday.builder() - .date(LocalDate.of(currentYear, 12, 25)) - .name("Christmas Day") - .description("National Holiday") - .build() + Holiday.builder() + .date(LocalDate.of(currentYear, 1, 1)) + .name("New Year's Day") + .description("National Holiday") + .build(), + + Holiday.builder() + .date(LocalDate.of(currentYear, 2, 4)) + .name("Independence Day") + .description("National Holiday") + .build(), + + Holiday.builder() + .date(LocalDate.of(currentYear, 5, 1)) + .name("May Day") + .description("National Holiday") + .build(), + + Holiday.builder() + .date(LocalDate.of(currentYear, 12, 25)) + .name("Christmas Day") + .description("National Holiday") + .build() ); repository.saveAll(holidays); @@ -320,103 +319,103 @@ private void seedAppointments(AppointmentRepository appointmentRepository, Servi // Past appointment - COMPLETED appointments.add(Appointment.builder() - .customerId(CUSTOMER_1_ID) - .vehicleId(VEHICLE_1_ID) - .assignedEmployeeIds(Set.of(EMPLOYEE_1_ID)) - .assignedBayId(bays.get(0).getId()) - .confirmationNumber("APT-" + today.getYear() + "-" + String.format("%06d", confirmationCounter++)) - .serviceType("Oil Change") - .requestedDateTime(today.minusDays(7).atTime(10, 0)) - .status(AppointmentStatus.COMPLETED) - .specialInstructions("Please check tire pressure as well") - .build()); + .customerId(CUSTOMER_1_ID) + .vehicleId(VEHICLE_1_ID) + .assignedEmployeeIds(Set.of(EMPLOYEE_ID)) // <-- CHANGED + .assignedBayId(bays.get(0).getId()) + .confirmationNumber("APT-" + today.getYear() + "-" + String.format("%06d", confirmationCounter++)) + .serviceType("Oil Change") + .requestedDateTime(today.minusDays(7).atTime(10, 0)) + .status(AppointmentStatus.COMPLETED) + .specialInstructions("Please check tire pressure as well") + .build()); // Past appointment - COMPLETED appointments.add(Appointment.builder() - .customerId(CUSTOMER_2_ID) - .vehicleId(VEHICLE_3_ID) - .assignedEmployeeIds(Set.of(EMPLOYEE_2_ID)) - .assignedBayId(bays.get(1).getId()) - .confirmationNumber("APT-" + today.getYear() + "-" + String.format("%06d", confirmationCounter++)) - .serviceType("Brake Service") - .requestedDateTime(today.minusDays(5).atTime(14, 0)) - .status(AppointmentStatus.COMPLETED) - .specialInstructions("Brake pads were making noise") - .build()); + .customerId(CUSTOMER_2_ID) + .vehicleId(VEHICLE_3_ID) + .assignedEmployeeIds(Set.of(EMPLOYEE_ID)) // <-- CHANGED + .assignedBayId(bays.get(1).getId()) + .confirmationNumber("APT-" + today.getYear() + "-" + String.format("%06d", confirmationCounter++)) + .serviceType("Brake Service") + .requestedDateTime(today.minusDays(5).atTime(14, 0)) + .status(AppointmentStatus.COMPLETED) + .specialInstructions("Brake pads were making noise") + .build()); // Today's appointment - IN_PROGRESS appointments.add(Appointment.builder() - .customerId(CUSTOMER_1_ID) - .vehicleId(VEHICLE_2_ID) - .assignedEmployeeIds(Set.of(EMPLOYEE_1_ID)) - .assignedBayId(bays.get(0).getId()) - .confirmationNumber("APT-" + today.getYear() + "-" + String.format("%06d", confirmationCounter++)) - .serviceType("Wheel Alignment") - .requestedDateTime(today.atTime(9, 0)) - .status(AppointmentStatus.IN_PROGRESS) - .specialInstructions("Car pulls to the right") - .build()); + .customerId(CUSTOMER_1_ID) + .vehicleId(VEHICLE_2_ID) + .assignedEmployeeIds(Set.of(EMPLOYEE_ID)) // <-- CHANGED + .assignedBayId(bays.get(0).getId()) + .confirmationNumber("APT-" + today.getYear() + "-" + String.format("%06d", confirmationCounter++)) + .serviceType("Wheel Alignment") + .requestedDateTime(today.atTime(9, 0)) + .status(AppointmentStatus.IN_PROGRESS) + .specialInstructions("Car pulls to the right") + .build()); // Today's appointment - CONFIRMED appointments.add(Appointment.builder() - .customerId(CUSTOMER_2_ID) - .vehicleId(VEHICLE_4_ID) - .assignedEmployeeIds(Set.of(EMPLOYEE_3_ID)) - .assignedBayId(bays.get(2).getId()) - .confirmationNumber("APT-" + today.getYear() + "-" + String.format("%06d", confirmationCounter++)) - .serviceType("Engine Diagnostic") - .requestedDateTime(today.atTime(11, 0)) - .status(AppointmentStatus.CONFIRMED) - .specialInstructions("Check engine light is on") - .build()); - - // Tomorrow's appointment - CONFIRMED (with 2 employees) + .customerId(CUSTOMER_2_ID) + .vehicleId(VEHICLE_4_ID) + .assignedEmployeeIds(Set.of(EMPLOYEE_ID)) // <-- CHANGED + .assignedBayId(bays.get(2).getId()) + .confirmationNumber("APT-" + today.getYear() + "-" + String.format("%06d", confirmationCounter++)) + .serviceType("Engine Diagnostic") + .requestedDateTime(today.atTime(11, 0)) + .status(AppointmentStatus.CONFIRMED) + .specialInstructions("Check engine light is on") + .build()); + + // Tomorrow's appointment - CONFIRMED (with 1 employee) appointments.add(Appointment.builder() - .customerId(CUSTOMER_1_ID) - .vehicleId(VEHICLE_1_ID) - .assignedEmployeeIds(Set.of(EMPLOYEE_1_ID, EMPLOYEE_2_ID)) - .assignedBayId(bays.get(1).getId()) - .confirmationNumber("APT-" + today.getYear() + "-" + String.format("%06d", confirmationCounter++)) - .serviceType("AC Service") - .requestedDateTime(today.plusDays(1).atTime(10, 0)) - .status(AppointmentStatus.CONFIRMED) - .specialInstructions("AC not cooling properly - Complex job needs 2 mechanics") - .build()); + .customerId(CUSTOMER_1_ID) + .vehicleId(VEHICLE_1_ID) + .assignedEmployeeIds(Set.of(EMPLOYEE_ID)) // <-- CHANGED (This was the line that failed) + .assignedBayId(bays.get(1).getId()) + .confirmationNumber("APT-" + today.getYear() + "-" + String.format("%06d", confirmationCounter++)) + .serviceType("AC Service") + .requestedDateTime(today.plusDays(1).atTime(10, 0)) + .status(AppointmentStatus.CONFIRMED) + .specialInstructions("AC not cooling properly") // <-- Modified comment + .build()); // Future appointment - PENDING appointments.add(Appointment.builder() - .customerId(CUSTOMER_2_ID) - .vehicleId(VEHICLE_3_ID) - .confirmationNumber("APT-" + today.getYear() + "-" + String.format("%06d", confirmationCounter++)) - .serviceType("Full Service") - .requestedDateTime(today.plusDays(3).atTime(9, 0)) - .status(AppointmentStatus.PENDING) - .specialInstructions("Complete service needed, car has 50,000 km") - .build()); + .customerId(CUSTOMER_2_ID) + .vehicleId(VEHICLE_3_ID) + .confirmationNumber("APT-" + today.getYear() + "-" + String.format("%06d", confirmationCounter++)) + .serviceType("Full Service") + .requestedDateTime(today.plusDays(3).atTime(9, 0)) + .status(AppointmentStatus.PENDING) + .specialInstructions("Complete service needed, car has 50,000 km") + .build()); // Future appointment - PENDING appointments.add(Appointment.builder() - .customerId(CUSTOMER_1_ID) - .vehicleId(VEHICLE_2_ID) - .confirmationNumber("APT-" + today.getYear() + "-" + String.format("%06d", confirmationCounter++)) - .serviceType("Tire Rotation") - .requestedDateTime(today.plusDays(5).atTime(14, 30)) - .status(AppointmentStatus.PENDING) - .specialInstructions(null) - .build()); + .customerId(CUSTOMER_1_ID) + .vehicleId(VEHICLE_2_ID) + .confirmationNumber("APT-" + today.getYear() + "-" + String.format("%06d", confirmationCounter++)) + .serviceType("Tire Rotation") + .requestedDateTime(today.plusDays(5).atTime(14, 30)) + .status(AppointmentStatus.PENDING) + .specialInstructions(null) + .build()); // Cancelled appointment appointments.add(Appointment.builder() - .customerId(CUSTOMER_1_ID) - .vehicleId(VEHICLE_1_ID) - .confirmationNumber("APT-" + today.getYear() + "-" + String.format("%06d", confirmationCounter++)) - .serviceType("Battery Replacement") - .requestedDateTime(today.plusDays(2).atTime(15, 0)) - .status(AppointmentStatus.CANCELLED) - .specialInstructions("Battery issue resolved") - .build()); + .customerId(CUSTOMER_1_ID) + .vehicleId(VEHICLE_1_ID) + .confirmationNumber("APT-" + today.getYear() + "-" + String.format("%06d", confirmationCounter++)) + .serviceType("Battery Replacement") + .requestedDateTime(today.plusDays(2).atTime(15, 0)) + .status(AppointmentStatus.CANCELLED) + .specialInstructions("Battery issue resolved") + .build()); appointmentRepository.saveAll(appointments); log.info("Seeded {} sample appointments", appointments.size()); } -} +} \ No newline at end of file From 96dcb2dc279fb9c3092e227db3af7d10883e7abb Mon Sep 17 00:00:00 2001 From: RandithaK Date: Tue, 11 Nov 2025 14:51:08 +0530 Subject: [PATCH 10/16] Add WebClient configuration for Admin Service and update ServiceTypeServiceImpl to use it --- appointment-service/pom.xml | 8 + .../config/WebClientConfig.java | 68 ++++++ .../service/impl/ServiceTypeServiceImpl.java | 193 +++++++++--------- .../src/main/resources/application.properties | 3 + 4 files changed, 177 insertions(+), 95 deletions(-) create mode 100644 appointment-service/src/main/java/com/techtorque/appointment_service/config/WebClientConfig.java diff --git a/appointment-service/pom.xml b/appointment-service/pom.xml index da77b51..0105691 100644 --- a/appointment-service/pom.xml +++ b/appointment-service/pom.xml @@ -42,6 +42,10 @@ org.springframework.boot spring-boot-starter-security + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + org.springframework.boot spring-boot-starter-validation @@ -50,6 +54,10 @@ org.springframework.boot spring-boot-starter-web + + org.springframework.boot + spring-boot-starter-webflux + org.springframework.boot diff --git a/appointment-service/src/main/java/com/techtorque/appointment_service/config/WebClientConfig.java b/appointment-service/src/main/java/com/techtorque/appointment_service/config/WebClientConfig.java new file mode 100644 index 0000000..7e137b1 --- /dev/null +++ b/appointment-service/src/main/java/com/techtorque/appointment_service/config/WebClientConfig.java @@ -0,0 +1,68 @@ +package com.techtorque.appointment_service.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; +import org.springframework.web.reactive.function.client.ClientRequest; +import org.springframework.web.reactive.function.client.ExchangeFilterFunction; +import org.springframework.web.reactive.function.client.WebClient; + +import jakarta.servlet.http.HttpServletRequest; + +@Configuration +public class WebClientConfig { + + @Value("${admin.service.url:http://localhost:8087}") + private String adminServiceUrl; + + /** + * JWT token propagation filter that extracts the token from the current request + * and adds it to outgoing requests. + */ + private ExchangeFilterFunction jwtTokenPropagationFilter() { + return (request, next) -> { + String token = null; + + // Try to get token from RequestContextHolder (HTTP request context) + ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attributes != null) { + HttpServletRequest httpRequest = attributes.getRequest(); + String authHeader = httpRequest.getHeader("Authorization"); + if (authHeader != null && authHeader.startsWith("Bearer ")) { + token = authHeader.substring(7); + } + } + + // If not found in request context, try SecurityContext + if (token == null) { + var authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication instanceof JwtAuthenticationToken jwtAuth) { + token = jwtAuth.getToken().getTokenValue(); + } + } + + // If token found, add it to the outgoing request + if (token != null) { + final String finalToken = token; + ClientRequest modifiedRequest = ClientRequest.from(request) + .header("Authorization", "Bearer " + finalToken) + .build(); + return next.exchange(modifiedRequest); + } + + return next.exchange(request); + }; + } + + @Bean(name = "adminServiceWebClient") + public WebClient adminServiceWebClient(WebClient.Builder builder) { + return builder + .baseUrl(adminServiceUrl) + .filter(jwtTokenPropagationFilter()) + .build(); + } +} diff --git a/appointment-service/src/main/java/com/techtorque/appointment_service/service/impl/ServiceTypeServiceImpl.java b/appointment-service/src/main/java/com/techtorque/appointment_service/service/impl/ServiceTypeServiceImpl.java index 3e70bdc..dee01bc 100644 --- a/appointment-service/src/main/java/com/techtorque/appointment_service/service/impl/ServiceTypeServiceImpl.java +++ b/appointment-service/src/main/java/com/techtorque/appointment_service/service/impl/ServiceTypeServiceImpl.java @@ -2,137 +2,140 @@ import com.techtorque.appointment_service.dto.request.ServiceTypeRequestDto; import com.techtorque.appointment_service.dto.response.ServiceTypeResponseDto; -import com.techtorque.appointment_service.entity.ServiceType; -import com.techtorque.appointment_service.repository.ServiceTypeRepository; import com.techtorque.appointment_service.service.ServiceTypeService; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.reactive.function.client.WebClient; import java.util.List; -import java.util.stream.Collectors; @Service @Transactional @Slf4j public class ServiceTypeServiceImpl implements ServiceTypeService { - private final ServiceTypeRepository serviceTypeRepository; + private final WebClient adminServiceWebClient; - public ServiceTypeServiceImpl(ServiceTypeRepository serviceTypeRepository) { - this.serviceTypeRepository = serviceTypeRepository; + public ServiceTypeServiceImpl(@Qualifier("adminServiceWebClient") WebClient adminServiceWebClient) { + this.adminServiceWebClient = adminServiceWebClient; } @Override public List getAllServiceTypes(boolean includeInactive) { - log.info("Fetching all service types (includeInactive={})", includeInactive); - - List serviceTypes = includeInactive - ? serviceTypeRepository.findAll() - : serviceTypeRepository.findByActiveTrue(); - - return serviceTypes.stream() - .map(this::convertToDto) - .collect(Collectors.toList()); + log.info("Fetching all service types from Admin Service (includeInactive={})", includeInactive); + + try { + // Call Admin Service public endpoint to get active service types + // Note: We ignore includeInactive parameter because public endpoint only returns active types + List serviceTypes = adminServiceWebClient.get() + .uri("/public/service-types") + .retrieve() + .bodyToFlux(ServiceTypeResponseDto.class) + .collectList() + .block(); + + log.info("Retrieved {} service types from Admin Service", serviceTypes != null ? serviceTypes.size() : 0); + return serviceTypes != null ? serviceTypes : List.of(); + } catch (Exception e) { + log.error("Failed to fetch service types from Admin Service", e); + throw new RuntimeException("Failed to fetch service types: " + e.getMessage()); + } } @Override public ServiceTypeResponseDto getServiceTypeById(String id) { - log.info("Fetching service type with ID: {}", id); - - ServiceType serviceType = serviceTypeRepository.findById(id) - .orElseThrow(() -> new IllegalArgumentException("Service type not found with ID: " + id)); - - return convertToDto(serviceType); + log.info("Fetching service type with ID from Admin Service: {}", id); + + try { + ServiceTypeResponseDto serviceType = adminServiceWebClient.get() + .uri("/public/service-types/" + id) + .retrieve() + .bodyToMono(ServiceTypeResponseDto.class) + .block(); + + if (serviceType == null) { + throw new IllegalArgumentException("Service type not found with ID: " + id); + } + + return serviceType; + } catch (Exception e) { + log.error("Failed to fetch service type from Admin Service", e); + throw new RuntimeException("Failed to fetch service type: " + e.getMessage()); + } } @Override public ServiceTypeResponseDto createServiceType(ServiceTypeRequestDto dto) { - log.info("Creating new service type: {}", dto.getName()); - - // Check if service type with same name already exists - serviceTypeRepository.findByNameAndActiveTrue(dto.getName()).ifPresent(existing -> { - throw new IllegalArgumentException("Service type with name '" + dto.getName() + "' already exists"); - }); - - ServiceType serviceType = ServiceType.builder() - .name(dto.getName()) - .category(dto.getCategory()) - .basePriceLKR(dto.getBasePriceLKR()) - .estimatedDurationMinutes(dto.getEstimatedDurationMinutes()) - .description(dto.getDescription()) - .active(dto.getActive()) - .build(); - - ServiceType saved = serviceTypeRepository.save(serviceType); - log.info("Service type created successfully with ID: {}", saved.getId()); - - return convertToDto(saved); + log.info("Creating service type in Admin Service: {}", dto.getName()); + + try { + ServiceTypeResponseDto created = adminServiceWebClient.post() + .uri("/admin/service-types") + .bodyValue(dto) + .retrieve() + .bodyToMono(ServiceTypeResponseDto.class) + .block(); + + log.info("Service type created successfully in Admin Service"); + return created; + } catch (Exception e) { + log.error("Failed to create service type in Admin Service", e); + throw new RuntimeException("Failed to create service type: " + e.getMessage()); + } } @Override public ServiceTypeResponseDto updateServiceType(String id, ServiceTypeRequestDto dto) { - log.info("Updating service type with ID: {}", id); - - ServiceType serviceType = serviceTypeRepository.findById(id) - .orElseThrow(() -> new IllegalArgumentException("Service type not found with ID: " + id)); - - // Check if new name conflicts with existing service types - if (!serviceType.getName().equals(dto.getName())) { - serviceTypeRepository.findByNameAndActiveTrue(dto.getName()).ifPresent(existing -> { - if (!existing.getId().equals(id)) { - throw new IllegalArgumentException("Service type with name '" + dto.getName() + "' already exists"); - } - }); + log.info("Updating service type in Admin Service with ID: {}", id); + + try { + ServiceTypeResponseDto updated = adminServiceWebClient.put() + .uri("/admin/service-types/" + id) + .bodyValue(dto) + .retrieve() + .bodyToMono(ServiceTypeResponseDto.class) + .block(); + + log.info("Service type updated successfully in Admin Service"); + return updated; + } catch (Exception e) { + log.error("Failed to update service type in Admin Service", e); + throw new RuntimeException("Failed to update service type: " + e.getMessage()); } - - serviceType.setName(dto.getName()); - serviceType.setCategory(dto.getCategory()); - serviceType.setBasePriceLKR(dto.getBasePriceLKR()); - serviceType.setEstimatedDurationMinutes(dto.getEstimatedDurationMinutes()); - serviceType.setDescription(dto.getDescription()); - serviceType.setActive(dto.getActive()); - - ServiceType updated = serviceTypeRepository.save(serviceType); - log.info("Service type updated successfully: {}", id); - - return convertToDto(updated); } @Override public void deleteServiceType(String id) { - log.info("Deactivating service type with ID: {}", id); - - ServiceType serviceType = serviceTypeRepository.findById(id) - .orElseThrow(() -> new IllegalArgumentException("Service type not found with ID: " + id)); - - serviceType.setActive(false); - serviceTypeRepository.save(serviceType); - - log.info("Service type deactivated successfully: {}", id); + log.info("Deleting service type in Admin Service with ID: {}", id); + + try { + adminServiceWebClient.delete() + .uri("/admin/service-types/" + id) + .retrieve() + .bodyToMono(Void.class) + .block(); + + log.info("Service type deleted successfully in Admin Service"); + } catch (Exception e) { + log.error("Failed to delete service type in Admin Service", e); + throw new RuntimeException("Failed to delete service type: " + e.getMessage()); + } } @Override public List getServiceTypesByCategory(String category) { - log.info("Fetching service types by category: {}", category); - - List serviceTypes = serviceTypeRepository.findByCategoryAndActiveTrue(category); - - return serviceTypes.stream() - .map(this::convertToDto) - .collect(Collectors.toList()); - } - - private ServiceTypeResponseDto convertToDto(ServiceType serviceType) { - return ServiceTypeResponseDto.builder() - .id(serviceType.getId()) - .name(serviceType.getName()) - .category(serviceType.getCategory()) - .basePriceLKR(serviceType.getBasePriceLKR()) - .estimatedDurationMinutes(serviceType.getEstimatedDurationMinutes()) - .description(serviceType.getDescription()) - .active(serviceType.getActive()) - .createdAt(serviceType.getCreatedAt()) - .updatedAt(serviceType.getUpdatedAt()) - .build(); + log.info("Fetching service types by category from Admin Service: {}", category); + + try { + // Get all active service types and filter by category + List allServiceTypes = getAllServiceTypes(false); + return allServiceTypes.stream() + .filter(st -> category.equalsIgnoreCase(st.getCategory())) + .toList(); + } catch (Exception e) { + log.error("Failed to fetch service types by category", e); + throw new RuntimeException("Failed to fetch service types by category: " + e.getMessage()); + } } } diff --git a/appointment-service/src/main/resources/application.properties b/appointment-service/src/main/resources/application.properties index 717b0ed..2b432a1 100644 --- a/appointment-service/src/main/resources/application.properties +++ b/appointment-service/src/main/resources/application.properties @@ -17,5 +17,8 @@ spring.jpa.properties.hibernate.format_sql=true # Development/Production Profile spring.profiles.active=${SPRING_PROFILE:dev} +# Service URLs +admin.service.url=${ADMIN_SERVICE_URL:http://localhost:8087} + # OpenAPI access URL # http://localhost:8083/swagger-ui/index.html \ No newline at end of file From e9653a2c08667923d45c18406780e4c4a00dd34d Mon Sep 17 00:00:00 2001 From: RandithaK Date: Tue, 11 Nov 2025 15:01:37 +0530 Subject: [PATCH 11/16] Refactor AppointmentServiceImpl to replace ServiceTypeRepository with ServiceTypeService for improved service type management --- .../service/impl/AppointmentServiceImpl.java | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/appointment-service/src/main/java/com/techtorque/appointment_service/service/impl/AppointmentServiceImpl.java b/appointment-service/src/main/java/com/techtorque/appointment_service/service/impl/AppointmentServiceImpl.java index 7784304..af34a2c 100644 --- a/appointment-service/src/main/java/com/techtorque/appointment_service/service/impl/AppointmentServiceImpl.java +++ b/appointment-service/src/main/java/com/techtorque/appointment_service/service/impl/AppointmentServiceImpl.java @@ -6,6 +6,7 @@ import com.techtorque.appointment_service.exception.*; import com.techtorque.appointment_service.repository.*; import com.techtorque.appointment_service.service.AppointmentService; +import com.techtorque.appointment_service.service.ServiceTypeService; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -19,7 +20,7 @@ public class AppointmentServiceImpl implements AppointmentService { private final AppointmentRepository appointmentRepository; - private final ServiceTypeRepository serviceTypeRepository; + private final ServiceTypeService serviceTypeService; private final ServiceBayRepository serviceBayRepository; private final BusinessHoursRepository businessHoursRepository; private final HolidayRepository holidayRepository; @@ -30,14 +31,14 @@ public class AppointmentServiceImpl implements AppointmentService { public AppointmentServiceImpl( AppointmentRepository appointmentRepository, - ServiceTypeRepository serviceTypeRepository, + ServiceTypeService serviceTypeService, ServiceBayRepository serviceBayRepository, BusinessHoursRepository businessHoursRepository, HolidayRepository holidayRepository, com.techtorque.appointment_service.service.NotificationClient notificationClient, com.techtorque.appointment_service.client.TimeLoggingClient timeLoggingClient) { this.appointmentRepository = appointmentRepository; - this.serviceTypeRepository = serviceTypeRepository; + this.serviceTypeService = serviceTypeService; this.serviceBayRepository = serviceBayRepository; this.businessHoursRepository = businessHoursRepository; this.holidayRepository = holidayRepository; @@ -49,7 +50,11 @@ public AppointmentServiceImpl( public AppointmentResponseDto bookAppointment(AppointmentRequestDto dto, String customerId) { log.info("Booking appointment for customer: {}", customerId); - ServiceType serviceType = serviceTypeRepository.findByNameAndActiveTrue(dto.getServiceType()) + // Fetch service type from Admin Service (via ServiceTypeService) + List allServiceTypes = serviceTypeService.getAllServiceTypes(false); + ServiceTypeResponseDto serviceType = allServiceTypes.stream() + .filter(st -> st.getName().equals(dto.getServiceType())) + .findFirst() .orElseThrow(() -> new IllegalArgumentException("Invalid service type: " + dto.getServiceType())); validateAppointmentDateTime(dto.getRequestedDateTime(), serviceType.getEstimatedDurationMinutes()); From 8167bdb381ecc8f8f37f57d3b82ebcd8cb38f08f Mon Sep 17 00:00:00 2001 From: RandithaK Date: Tue, 11 Nov 2025 15:52:21 +0530 Subject: [PATCH 12/16] Implement intelligent time tracking with Clock In/Out functionality, integrating with Time Logging Service. Added TimeSession entity, repository, and response DTO. Updated AppointmentServiceImpl to handle clock in/out logic and notify customers. Enhanced TimeLoggingClient for JWT authentication. Created integration tests for time tracking features. --- TIME_TRACKING_IMPLEMENTATION.md | 417 ++++++++++++++++++ .../client/TimeLoggingClient.java | 102 ++++- .../controller/AppointmentController.java | 38 ++ .../dto/response/TimeSessionResponse.java | 21 + .../entity/TimeSession.java | 50 +++ .../repository/TimeSessionRepository.java | 20 + .../service/AppointmentService.java | 7 + .../service/impl/AppointmentServiceImpl.java | 166 ++++++- test-time-tracking.sh | 222 ++++++++++ 9 files changed, 1022 insertions(+), 21 deletions(-) create mode 100644 TIME_TRACKING_IMPLEMENTATION.md create mode 100644 appointment-service/src/main/java/com/techtorque/appointment_service/dto/response/TimeSessionResponse.java create mode 100644 appointment-service/src/main/java/com/techtorque/appointment_service/entity/TimeSession.java create mode 100644 appointment-service/src/main/java/com/techtorque/appointment_service/repository/TimeSessionRepository.java create mode 100755 test-time-tracking.sh diff --git a/TIME_TRACKING_IMPLEMENTATION.md b/TIME_TRACKING_IMPLEMENTATION.md new file mode 100644 index 0000000..1a430e8 --- /dev/null +++ b/TIME_TRACKING_IMPLEMENTATION.md @@ -0,0 +1,417 @@ +# Intelligent Time Tracking Implementation + +## Overview +Implemented automated time tracking with Clock In/Out functionality that integrates with the Time Logging Service. This eliminates manual time logging and provides live timer functionality for employees. + +## Backend Implementation + +### 1. New Entity: TimeSession +**Location:** `entity/TimeSession.java` + +Tracks active clock-in sessions locally in Appointment Service: +- `id` - UUID primary key +- `appointmentId` - Links to appointment +- `employeeId` - Employee who clocked in +- `clockInTime` - Timestamp when work started (auto-set) +- `clockOutTime` - Timestamp when work ended +- `active` - Boolean flag for active sessions +- `timeLogId` - Reference to Time Logging Service entry + +### 2. Repository: TimeSessionRepository +**Location:** `repository/TimeSessionRepository.java` + +JPA repository with custom queries: +- `findByAppointmentIdAndActiveTrue()` - Find active session for appointment +- `findByAppointmentIdAndEmployeeIdAndActiveTrue()` - Find specific employee's active session +- `findByEmployeeIdAndActiveTrue()` - All active sessions for employee +- `findByEmployeeIdOrderByClockInTimeDesc()` - Employee's session history + +### 3. DTO: TimeSessionResponse +**Location:** `dto/response/TimeSessionResponse.java` + +API response containing: +- Session details (id, appointmentId, employeeId) +- Time information (clockInTime, clockOutTime, active) +- **Calculated fields:** + - `elapsedSeconds` - For live timer display + - `hoursWorked` - Total hours when completed + +### 4. Updated TimeLoggingClient +**Location:** `client/TimeLoggingClient.java` + +Added JWT-authenticated methods: +- `createTimeLog()` - Creates entry in Time Logging Service, returns timeLogId +- `updateTimeLog()` - Updates existing log with actual hours worked +- `createAuthHeaders()` - Extracts JWT token for service-to-service auth + +### 5. Service Methods +**Location:** `service/impl/AppointmentServiceImpl.java` + +#### `clockIn(appointmentId, employeeId)` +1. Validates appointment exists and employee is assigned +2. Checks for existing active session +3. Creates time log in Time Logging Service (0 hours initially) +4. Creates local TimeSession entity +5. Updates appointment status to `IN_PROGRESS` +6. Sends notification to customer +7. Returns TimeSessionResponse with clockInTime + +#### `clockOut(appointmentId, employeeId)` +1. Finds active TimeSession +2. Sets clockOutTime to now +3. Calculates hours worked: `(clockOutTime - clockInTime) / 60 minutes` +4. Updates Time Logging Service with actual hours +5. Marks TimeSession as inactive +6. Updates appointment status to `COMPLETED` +7. Sends completion notification with hours worked +8. Returns TimeSessionResponse with total hours + +#### `getActiveTimeSession(appointmentId, employeeId)` +- Retrieves active session if exists +- Calculates `elapsedSeconds` for live timer +- Returns null if no active session + +### 6. Controller Endpoints +**Location:** `controller/AppointmentController.java` + +| Endpoint | Method | Role | Description | +|----------|--------|------|-------------| +| `/appointments/{id}/clock-in` | POST | EMPLOYEE | Start time tracking | +| `/appointments/{id}/clock-out` | POST | EMPLOYEE | Stop time tracking | +| `/appointments/{id}/time-session` | GET | EMPLOYEE | Get active session (for timer) | + +All endpoints require JWT authentication with `X-User-Subject` header. + +### 7. Integration with Existing Flow +**Updated:** `acceptVehicleArrival()` method + +Now automatically calls `clockIn()` when employee accepts vehicle arrival, eliminating the need for manual clock-in after accepting work. + +## API Usage + +### Clock In +```bash +POST /appointments/{appointmentId}/clock-in +Headers: + Authorization: Bearer + X-User-Subject: + +Response: +{ + "id": "uuid", + "appointmentId": "appt-123", + "employeeId": "emp-456", + "clockInTime": "2025-01-20T10:30:00", + "clockOutTime": null, + "active": true, + "elapsedSeconds": 0, + "hoursWorked": null +} +``` + +### Get Active Session (for live timer) +```bash +GET /appointments/{appointmentId}/time-session +Headers: + Authorization: Bearer + X-User-Subject: + +Response: +{ + "id": "uuid", + "appointmentId": "appt-123", + "employeeId": "emp-456", + "clockInTime": "2025-01-20T10:30:00", + "clockOutTime": null, + "active": true, + "elapsedSeconds": 3600, // Updated in real-time + "hoursWorked": null +} +``` + +### Clock Out +```bash +POST /appointments/{appointmentId}/clock-out +Headers: + Authorization: Bearer + X-User-Subject: + +Response: +{ + "id": "uuid", + "appointmentId": "appt-123", + "employeeId": "emp-456", + "clockInTime": "2025-01-20T10:30:00", + "clockOutTime": "2025-01-20T12:45:00", + "active": false, + "elapsedSeconds": 8100, + "hoursWorked": 2.25 +} +``` + +## Time Logging Service Integration + +### Flow Diagram +``` +Clock In: + Appointment Service → Time Logging Service + POST /time-logs + { + "employeeId": "emp-123", + "serviceId": "appt-456", + "hours": 0, + "description": "Work started...", + "date": "2025-01-20", + "workType": "SERVICE" + } + ← Returns: { "id": "log-789", ... } + +Clock Out: + Appointment Service → Time Logging Service + PUT /time-logs/log-789 + { + "hours": 2.25, + "description": "Completed: 2.25 hours worked" + } +``` + +### JWT Authentication +All Time Logging Service requests include: +``` +Headers: + Authorization: Bearer + X-User-Subject: +``` + +Token is extracted from SecurityContext OAuth2 authentication. + +## Frontend Implementation Requirements + +### 1. Appointment Details Page Updates + +#### Add Clock In/Out Button Component +```typescript +// Conditional rendering based on appointment status +{appointment.status === 'CONFIRMED' && !activeSession && ( + +)} + +{appointment.status === 'IN_PROGRESS' && activeSession && ( +
+ + +
+)} +``` + +#### API Integration +```typescript +// Clock in +const handleClockIn = async () => { + const response = await fetch( + `/api/appointments/${appointmentId}/clock-in`, + { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'X-User-Subject': employeeId + } + } + ); + const session = await response.json(); + setActiveSession(session); +}; + +// Clock out +const handleClockOut = async () => { + const response = await fetch( + `/api/appointments/${appointmentId}/clock-out`, + { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'X-User-Subject': employeeId + } + } + ); + const session = await response.json(); + setActiveSession(null); + showNotification(`Work completed! ${session.hoursWorked} hours logged.`); +}; +``` + +### 2. Live Timer Component +```typescript +const Timer: React.FC<{ elapsedSeconds: number }> = ({ elapsedSeconds }) => { + const [seconds, setSeconds] = useState(elapsedSeconds); + + useEffect(() => { + const interval = setInterval(async () => { + // Fetch updated elapsed time from server + const response = await fetch( + `/api/appointments/${appointmentId}/time-session`, + { + headers: { + 'Authorization': `Bearer ${token}`, + 'X-User-Subject': employeeId + } + } + ); + + if (response.ok) { + const session = await response.json(); + setSeconds(session.elapsedSeconds); + } + }, 1000); // Update every second + + return () => clearInterval(interval); + }, []); + + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; + + return ( +
+ + {String(hours).padStart(2, '0')}: + {String(minutes).padStart(2, '0')}: + {String(secs).padStart(2, '0')} + + Time Elapsed +
+ ); +}; +``` + +### 3. Time Logs Summary Page + +#### Stats Display +Call Time Logging Service endpoints: +```typescript +// Today's hours +GET /api/time-logs/summary?period=daily&date=2025-01-20 + +// This week +GET /api/time-logs/summary?period=weekly&date=2025-01-20 + +// Overall stats +GET /api/time-logs/stats + +// Recent logs +GET /api/time-logs?employeeId={id}&fromDate=2025-01-01&toDate=2025-01-31 +``` + +#### UI Layout +``` +┌─────────────────────────────────────┐ +│ Time Tracking Summary │ +├─────────────────────────────────────┤ +│ Today: 4.5 hours │ +│ This Week: 22.0 hours │ +│ This Month: 88.5 hours │ +│ Total: 450.0 hours │ +├─────────────────────────────────────┤ +│ Recent Time Logs │ +│ ┌────────────────────────────────┐ │ +│ │ Jan 20 | Oil Change | 2.25h │ │ +│ │ Jan 19 | Brake Repair | 3.5h │ │ +│ │ Jan 18 | Inspection | 1.0h │ │ +│ └────────────────────────────────┘ │ +└─────────────────────────────────────┘ +``` + +## Benefits + +### For Employees +✅ One-click clock in/out +✅ Live timer shows exactly how long they've been working +✅ No manual time entry required +✅ Automatic status updates + +### For Business +✅ Accurate time tracking +✅ Real-time work status monitoring +✅ Automated customer notifications +✅ Historical time data for analytics + +### Technical +✅ Single source of truth (Time Logging Service) +✅ Local session tracking for quick queries +✅ JWT authentication for security +✅ Transaction-safe operations + +## Testing Checklist + +- [ ] Clock in creates time log with 0 hours +- [ ] Clock in updates appointment status to IN_PROGRESS +- [ ] Clock in sends customer notification +- [ ] Cannot clock in twice for same appointment +- [ ] Get active session returns correct elapsed time +- [ ] Clock out calculates correct hours +- [ ] Clock out updates time log with actual hours +- [ ] Clock out updates appointment status to COMPLETED +- [ ] Clock out sends completion notification with hours +- [ ] Cannot clock out without active session +- [ ] Authorization: Only assigned employees can clock in/out +- [ ] JWT token properly propagated to Time Logging Service +- [ ] Frontend timer updates every second +- [ ] Summary page shows correct totals + +## Next Steps + +1. **Build and deploy Appointment Service** + ```bash + cd Appointment_Service + mvn clean package + docker build -t appointment-service . + ``` + +2. **Update Frontend** + - Add Timer component to appointment details page + - Implement clock in/out buttons + - Create time logs summary page + +3. **Test Integration** + - Test clock in → verify time log created + - Let timer run for 1 minute + - Clock out → verify hours logged correctly + - Check Time Logging Service database + +4. **Documentation** + - Update API documentation + - Add user guide for employees + - Create admin monitoring dashboard + +## Configuration + +### Application Properties +No additional configuration needed. Uses existing: +- JWT authentication setup +- Time Logging Service URL from WebClient config +- Database connection for TimeSession table + +### Database Migration +Table created automatically by JPA: +```sql +CREATE TABLE time_session ( + id VARCHAR(255) PRIMARY KEY, + appointment_id VARCHAR(255) NOT NULL, + employee_id VARCHAR(255) NOT NULL, + clock_in_time TIMESTAMP NOT NULL, + clock_out_time TIMESTAMP, + active BOOLEAN NOT NULL DEFAULT true, + time_log_id VARCHAR(255) NOT NULL +); +``` + +## Support +For issues or questions: +- Check logs: `docker logs appointment-service` +- Verify Time Logging Service connectivity +- Confirm JWT token in SecurityContext +- Check database for TimeSession entries diff --git a/appointment-service/src/main/java/com/techtorque/appointment_service/client/TimeLoggingClient.java b/appointment-service/src/main/java/com/techtorque/appointment_service/client/TimeLoggingClient.java index 62d38e9..4ba2549 100644 --- a/appointment-service/src/main/java/com/techtorque/appointment_service/client/TimeLoggingClient.java +++ b/appointment-service/src/main/java/com/techtorque/appointment_service/client/TimeLoggingClient.java @@ -2,9 +2,9 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; +import org.springframework.http.*; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; import org.springframework.stereotype.Component; import org.springframework.web.client.RestTemplate; @@ -14,7 +14,7 @@ /** * Client for communicating with Time Logging Service - * Used to automatically create time log entries when employees start work + * Used to automatically create time log entries when employees clock in/out */ @Component @Slf4j @@ -30,41 +30,113 @@ public TimeLoggingClient(RestTemplate restTemplate, } /** - * Create a time log entry when employee accepts vehicle arrival + * Create a time log entry when employee clocks in * This starts tracking time for the appointment/service * - * @param employeeId The employee who accepted the vehicle + * @param employeeId The employee who is clocking in * @param appointmentId The appointment ID (used as serviceId in time logging) * @param description Description of the work + * @param hours Initial hours (will be 0 when clocking in, updated on clock out) + * @return The time log ID from the Time Logging Service */ - public void startTimeLog(String employeeId, String appointmentId, String description) { + public String createTimeLog(String employeeId, String appointmentId, String description, double hours) { try { log.info("Creating time log for employee {} on appointment {}", employeeId, appointmentId); Map request = new HashMap<>(); - request.put("employeeId", employeeId); request.put("serviceId", appointmentId); // Using appointmentId as serviceId request.put("date", LocalDate.now().toString()); - request.put("hours", 0.0); // Will be calculated when work completes + request.put("hours", hours); request.put("description", description); request.put("workType", "APPOINTMENT"); - HttpHeaders headers = new HttpHeaders(); + HttpHeaders headers = createAuthHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); HttpEntity> entity = new HttpEntity<>(request, headers); - restTemplate.postForEntity( - timeLoggingServiceUrl + "/api/v1/time-logs", + ResponseEntity response = restTemplate.postForEntity( + timeLoggingServiceUrl + "/time-logs", entity, - Object.class + Map.class ); - log.debug("Time log created successfully"); + if (response.getBody() != null && response.getBody().containsKey("id")) { + String timeLogId = (String) response.getBody().get("id"); + log.debug("Time log created successfully with ID: {}", timeLogId); + return timeLogId; + } + + log.warn("Time log created but no ID returned"); + return null; } catch (Exception e) { - // Log error but don't throw - time logging failure shouldn't break appointment operations log.error("Failed to create time log for employee {}: {}", employeeId, e.getMessage()); + return null; } } + + /** + * Update a time log entry when employee clocks out + * + * @param timeLogId The time log ID to update + * @param hours The total hours worked + * @param description Updated description + */ + public void updateTimeLog(String timeLogId, double hours, String description) { + try { + log.info("Updating time log {} with {} hours", timeLogId, hours); + + Map request = new HashMap<>(); + request.put("hours", hours); + if (description != null) { + request.put("description", description); + } + + HttpHeaders headers = createAuthHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity> entity = new HttpEntity<>(request, headers); + + restTemplate.exchange( + timeLoggingServiceUrl + "/time-logs/" + timeLogId, + HttpMethod.PUT, + entity, + Object.class + ); + + log.debug("Time log updated successfully"); + + } catch (Exception e) { + log.error("Failed to update time log {}: {}", timeLogId, e.getMessage()); + } + } + + /** + * Create HTTP headers with JWT token for authentication + */ + private HttpHeaders createAuthHeaders() { + HttpHeaders headers = new HttpHeaders(); + + try { + var authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication instanceof JwtAuthenticationToken jwtAuth) { + String token = jwtAuth.getToken().getTokenValue(); + headers.set("Authorization", "Bearer " + token); + } + } catch (Exception e) { + log.warn("Failed to add authentication token to time logging request: {}", e.getMessage()); + } + + return headers; + } + + /** + * Legacy method for backward compatibility + * @deprecated Use createTimeLog instead + */ + @Deprecated + public void startTimeLog(String employeeId, String appointmentId, String description) { + createTimeLog(employeeId, appointmentId, description, 0.0); + } } diff --git a/appointment-service/src/main/java/com/techtorque/appointment_service/controller/AppointmentController.java b/appointment-service/src/main/java/com/techtorque/appointment_service/controller/AppointmentController.java index 3c2216c..38d0c34 100644 --- a/appointment-service/src/main/java/com/techtorque/appointment_service/controller/AppointmentController.java +++ b/appointment-service/src/main/java/com/techtorque/appointment_service/controller/AppointmentController.java @@ -187,4 +187,42 @@ public ResponseEntity completeWork( AppointmentResponseDto updated = appointmentService.completeWork(appointmentId, employeeId); return ResponseEntity.ok(updated); } + + @Operation(summary = "Clock in to start time tracking for an appointment") + @PostMapping("/{appointmentId}/clock-in") + @PreAuthorize("hasRole('EMPLOYEE')") + public ResponseEntity clockIn( + @PathVariable String appointmentId, + @RequestHeader("X-User-Subject") String employeeId) { + + TimeSessionResponse session = appointmentService.clockIn(appointmentId, employeeId); + return ResponseEntity.ok(session); + } + + @Operation(summary = "Clock out to stop time tracking for an appointment") + @PostMapping("/{appointmentId}/clock-out") + @PreAuthorize("hasRole('EMPLOYEE')") + public ResponseEntity clockOut( + @PathVariable String appointmentId, + @RequestHeader("X-User-Subject") String employeeId) { + + TimeSessionResponse session = appointmentService.clockOut(appointmentId, employeeId); + return ResponseEntity.ok(session); + } + + @Operation(summary = "Get active time session for an appointment") + @GetMapping("/{appointmentId}/time-session") + @PreAuthorize("hasRole('EMPLOYEE')") + public ResponseEntity getActiveTimeSession( + @PathVariable String appointmentId, + @RequestHeader("X-User-Subject") String employeeId) { + + TimeSessionResponse session = appointmentService.getActiveTimeSession(appointmentId, employeeId); + + if (session == null) { + return ResponseEntity.noContent().build(); + } + + return ResponseEntity.ok(session); + } } diff --git a/appointment-service/src/main/java/com/techtorque/appointment_service/dto/response/TimeSessionResponse.java b/appointment-service/src/main/java/com/techtorque/appointment_service/dto/response/TimeSessionResponse.java new file mode 100644 index 0000000..dffa0b4 --- /dev/null +++ b/appointment-service/src/main/java/com/techtorque/appointment_service/dto/response/TimeSessionResponse.java @@ -0,0 +1,21 @@ +package com.techtorque.appointment_service.dto.response; + +import lombok.*; + +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TimeSessionResponse { + + private String id; + private String appointmentId; + private String employeeId; + private LocalDateTime clockInTime; + private LocalDateTime clockOutTime; + private boolean active; + private Long elapsedSeconds; // Calculated field for timer + private Double hoursWorked; // Calculated when clocked out +} diff --git a/appointment-service/src/main/java/com/techtorque/appointment_service/entity/TimeSession.java b/appointment-service/src/main/java/com/techtorque/appointment_service/entity/TimeSession.java new file mode 100644 index 0000000..c67ee82 --- /dev/null +++ b/appointment-service/src/main/java/com/techtorque/appointment_service/entity/TimeSession.java @@ -0,0 +1,50 @@ +package com.techtorque.appointment_service.entity; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.GenericGenerator; + +import java.time.LocalDateTime; + +/** + * Entity to track active time sessions for appointments + * This allows employees to clock in/out and automatically track time + */ +@Entity +@Table(name = "time_sessions") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class TimeSession { + + @Id + @GeneratedValue(generator = "uuid") + @GenericGenerator(name = "uuid", strategy = "uuid2") + private String id; + + @Column(nullable = false) + private String appointmentId; + + @Column(nullable = false) + private String employeeId; + + @Column(nullable = false) + private LocalDateTime clockInTime; + + private LocalDateTime clockOutTime; + + @Column(nullable = false) + private boolean active; + + private String timeLogId; // Reference to the time log entry in Time Logging Service + + @PrePersist + protected void onCreate() { + if (clockInTime == null) { + clockInTime = LocalDateTime.now(); + } + active = true; + } +} diff --git a/appointment-service/src/main/java/com/techtorque/appointment_service/repository/TimeSessionRepository.java b/appointment-service/src/main/java/com/techtorque/appointment_service/repository/TimeSessionRepository.java new file mode 100644 index 0000000..2b8b691 --- /dev/null +++ b/appointment-service/src/main/java/com/techtorque/appointment_service/repository/TimeSessionRepository.java @@ -0,0 +1,20 @@ +package com.techtorque.appointment_service.repository; + +import com.techtorque.appointment_service.entity.TimeSession; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface TimeSessionRepository extends JpaRepository { + + Optional findByAppointmentIdAndActiveTrue(String appointmentId); + + Optional findByAppointmentIdAndEmployeeIdAndActiveTrue(String appointmentId, String employeeId); + + List findByEmployeeIdAndActiveTrue(String employeeId); + + List findByEmployeeIdOrderByClockInTimeDesc(String employeeId); +} diff --git a/appointment-service/src/main/java/com/techtorque/appointment_service/service/AppointmentService.java b/appointment-service/src/main/java/com/techtorque/appointment_service/service/AppointmentService.java index ea1d481..e907ca7 100644 --- a/appointment-service/src/main/java/com/techtorque/appointment_service/service/AppointmentService.java +++ b/appointment-service/src/main/java/com/techtorque/appointment_service/service/AppointmentService.java @@ -36,4 +36,11 @@ List getAppointmentsWithFilters( AppointmentResponseDto acceptVehicleArrival(String appointmentId, String employeeId); AppointmentResponseDto completeWork(String appointmentId, String employeeId); + + // Time tracking methods + TimeSessionResponse clockIn(String appointmentId, String employeeId); + + TimeSessionResponse clockOut(String appointmentId, String employeeId); + + TimeSessionResponse getActiveTimeSession(String appointmentId, String employeeId); } \ No newline at end of file diff --git a/appointment-service/src/main/java/com/techtorque/appointment_service/service/impl/AppointmentServiceImpl.java b/appointment-service/src/main/java/com/techtorque/appointment_service/service/impl/AppointmentServiceImpl.java index af34a2c..e8d49ab 100644 --- a/appointment-service/src/main/java/com/techtorque/appointment_service/service/impl/AppointmentServiceImpl.java +++ b/appointment-service/src/main/java/com/techtorque/appointment_service/service/impl/AppointmentServiceImpl.java @@ -24,6 +24,7 @@ public class AppointmentServiceImpl implements AppointmentService { private final ServiceBayRepository serviceBayRepository; private final BusinessHoursRepository businessHoursRepository; private final HolidayRepository holidayRepository; + private final TimeSessionRepository timeSessionRepository; private final com.techtorque.appointment_service.service.NotificationClient notificationClient; private final com.techtorque.appointment_service.client.TimeLoggingClient timeLoggingClient; @@ -35,6 +36,7 @@ public AppointmentServiceImpl( ServiceBayRepository serviceBayRepository, BusinessHoursRepository businessHoursRepository, HolidayRepository holidayRepository, + TimeSessionRepository timeSessionRepository, com.techtorque.appointment_service.service.NotificationClient notificationClient, com.techtorque.appointment_service.client.TimeLoggingClient timeLoggingClient) { this.appointmentRepository = appointmentRepository; @@ -42,6 +44,7 @@ public AppointmentServiceImpl( this.serviceBayRepository = serviceBayRepository; this.businessHoursRepository = businessHoursRepository; this.holidayRepository = holidayRepository; + this.timeSessionRepository = timeSessionRepository; this.notificationClient = notificationClient; this.timeLoggingClient = timeLoggingClient; } @@ -728,15 +731,12 @@ public AppointmentResponseDto acceptVehicleArrival(String appointmentId, String // Update appointment with vehicle arrival info appointment.setVehicleArrivedAt(LocalDateTime.now()); appointment.setVehicleAcceptedByEmployeeId(employeeId); - appointment.setStatus(AppointmentStatus.IN_PROGRESS); + // Status will be set to IN_PROGRESS by clockIn() method Appointment savedAppointment = appointmentRepository.save(appointment); - // Create time log entry to start tracking time - String description = String.format("Work started on %s for appointment %s", - appointment.getServiceType(), - appointment.getConfirmationNumber()); - timeLoggingClient.startTimeLog(employeeId, appointmentId, description); + // Use the new Clock In functionality to start time tracking + clockIn(appointmentId, employeeId); // Notify customer that work has started notificationClient.sendAppointmentNotification( @@ -792,4 +792,158 @@ public AppointmentResponseDto completeWork(String appointmentId, String employee return convertToDto(savedAppointment); } + + @Override + @Transactional + public TimeSessionResponse clockIn(String appointmentId, String employeeId) { + log.info("Clock in request - Appointment: {}, Employee: {}", appointmentId, employeeId); + + // 1. Verify appointment exists and employee is assigned + Appointment appointment = appointmentRepository.findById(appointmentId) + .orElseThrow(() -> new AppointmentNotFoundException("Appointment not found with ID: " + appointmentId)); + + if (!appointment.getAssignedEmployeeIds().contains(employeeId)) { + throw new UnauthorizedAccessException("Employee is not assigned to this appointment"); + } + + // 2. Check if there's already an active session + Optional existingSession = timeSessionRepository + .findByAppointmentIdAndEmployeeIdAndActiveTrue(appointmentId, employeeId); + + if (existingSession.isPresent()) { + log.warn("Employee {} already has an active time session for appointment {}", employeeId, appointmentId); + return convertToTimeSessionResponse(existingSession.get()); + } + + // 3. Create time log entry in Time Logging Service (with 0 hours initially) + String timeLogId = timeLoggingClient.createTimeLog( + employeeId, + appointmentId, + String.format("Work on %s - %s", appointment.getServiceType(), appointment.getConfirmationNumber()), + 0.0 + ); + + log.info("Created time log in Time Logging Service with ID: {}", timeLogId); + + // 4. Create TimeSession entity + TimeSession timeSession = new TimeSession(); + timeSession.setAppointmentId(appointmentId); + timeSession.setEmployeeId(employeeId); + timeSession.setTimeLogId(timeLogId); + // clockInTime and active are set by @PrePersist + + TimeSession savedSession = timeSessionRepository.save(timeSession); + log.info("Created time session with ID: {}", savedSession.getId()); + + // 5. Update appointment status to IN_PROGRESS + appointment.setStatus(AppointmentStatus.IN_PROGRESS); + appointmentRepository.save(appointment); + log.info("Updated appointment {} status to IN_PROGRESS", appointmentId); + + // 6. Notify customer that work has started + notificationClient.sendAppointmentNotification( + appointment.getCustomerId(), + "INFO", + "Work Started", + String.format("Your %s service has started! (Confirmation: %s)", + appointment.getServiceType(), + appointment.getConfirmationNumber()), + appointmentId + ); + + return convertToTimeSessionResponse(savedSession); + } + + @Override + @Transactional + public TimeSessionResponse clockOut(String appointmentId, String employeeId) { + log.info("Clock out request - Appointment: {}, Employee: {}", appointmentId, employeeId); + + // 1. Find active time session + TimeSession timeSession = timeSessionRepository + .findByAppointmentIdAndEmployeeIdAndActiveTrue(appointmentId, employeeId) + .orElseThrow(() -> new IllegalStateException( + "No active time session found for employee " + employeeId + " on appointment " + appointmentId)); + + // 2. Set clock out time + timeSession.setClockOutTime(LocalDateTime.now()); + timeSession.setActive(false); + + // 3. Calculate hours worked + Duration duration = Duration.between(timeSession.getClockInTime(), timeSession.getClockOutTime()); + double hoursWorked = duration.toMinutes() / 60.0; // Convert to hours with decimal precision + + log.info("Hours worked: {}", hoursWorked); + + // 4. Update time log in Time Logging Service with actual hours + timeLoggingClient.updateTimeLog( + timeSession.getTimeLogId(), + hoursWorked, + String.format("Completed: %.2f hours worked", hoursWorked) + ); + + // 5. Save updated time session + TimeSession savedSession = timeSessionRepository.save(timeSession); + log.info("Clock out completed for time session ID: {}", savedSession.getId()); + + // 6. Update appointment status to COMPLETED + Appointment appointment = appointmentRepository.findById(appointmentId) + .orElseThrow(() -> new AppointmentNotFoundException("Appointment not found with ID: " + appointmentId)); + + appointment.setStatus(AppointmentStatus.COMPLETED); + appointmentRepository.save(appointment); + log.info("Updated appointment {} status to COMPLETED", appointmentId); + + // 7. Notify customer that work is complete + notificationClient.sendAppointmentNotification( + appointment.getCustomerId(), + "SUCCESS", + "Work Completed", + String.format("Your %s service has been completed! (Confirmation: %s). Total hours: %.2f. Please proceed to payment.", + appointment.getServiceType(), + appointment.getConfirmationNumber(), + hoursWorked), + appointmentId + ); + + return convertToTimeSessionResponse(savedSession); + } + + @Override + public TimeSessionResponse getActiveTimeSession(String appointmentId, String employeeId) { + log.info("Getting active time session - Appointment: {}, Employee: {}", appointmentId, employeeId); + + TimeSession timeSession = timeSessionRepository + .findByAppointmentIdAndEmployeeIdAndActiveTrue(appointmentId, employeeId) + .orElse(null); + + if (timeSession == null) { + log.info("No active time session found"); + return null; + } + + return convertToTimeSessionResponse(timeSession); + } + + private TimeSessionResponse convertToTimeSessionResponse(TimeSession session) { + TimeSessionResponse response = new TimeSessionResponse(); + response.setId(session.getId()); + response.setAppointmentId(session.getAppointmentId()); + response.setEmployeeId(session.getEmployeeId()); + response.setClockInTime(session.getClockInTime()); + response.setClockOutTime(session.getClockOutTime()); + response.setActive(session.isActive()); + + // Calculate elapsed time + if (session.isActive()) { + Duration duration = Duration.between(session.getClockInTime(), LocalDateTime.now()); + response.setElapsedSeconds(duration.getSeconds()); + } else if (session.getClockOutTime() != null) { + Duration duration = Duration.between(session.getClockInTime(), session.getClockOutTime()); + response.setElapsedSeconds(duration.getSeconds()); + response.setHoursWorked(duration.toMinutes() / 60.0); + } + + return response; + } } diff --git a/test-time-tracking.sh b/test-time-tracking.sh new file mode 100755 index 0000000..5ed6f76 --- /dev/null +++ b/test-time-tracking.sh @@ -0,0 +1,222 @@ +#!/bin/bash + +# Time Tracking Integration Test Script +# Tests Clock In/Out functionality with Time Logging Service + +# Colors for output +GREEN='\033[0;32m' +RED='\033[0;31m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Configuration +APPOINTMENT_SERVICE_URL="http://localhost:8083" +TIME_LOGGING_SERVICE_URL="http://localhost:8085" +EMPLOYEE_ID="emp-test-001" +APPOINTMENT_ID="test-appointment-001" + +# Get JWT token (you'll need to implement this based on your auth service) +# For now, using a placeholder +JWT_TOKEN="your-jwt-token-here" + +echo -e "${YELLOW}========================================${NC}" +echo -e "${YELLOW}Time Tracking Integration Test${NC}" +echo -e "${YELLOW}========================================${NC}" +echo "" + +# Test 1: Clock In +echo -e "${YELLOW}Test 1: Clock In${NC}" +echo "POST ${APPOINTMENT_SERVICE_URL}/appointments/${APPOINTMENT_ID}/clock-in" +echo "" + +CLOCK_IN_RESPONSE=$(curl -s -w "\nHTTP_STATUS:%{http_code}" \ + -X POST "${APPOINTMENT_SERVICE_URL}/appointments/${APPOINTMENT_ID}/clock-in" \ + -H "Authorization: Bearer ${JWT_TOKEN}" \ + -H "X-User-Subject: ${EMPLOYEE_ID}" \ + -H "Content-Type: application/json") + +HTTP_STATUS=$(echo "$CLOCK_IN_RESPONSE" | grep "HTTP_STATUS" | cut -d: -f2) +RESPONSE_BODY=$(echo "$CLOCK_IN_RESPONSE" | sed '/HTTP_STATUS/d') + +if [ "$HTTP_STATUS" == "200" ]; then + echo -e "${GREEN}✓ Clock In Successful${NC}" + echo "$RESPONSE_BODY" | jq '.' + + # Extract timeLogId from response + TIME_SESSION_ID=$(echo "$RESPONSE_BODY" | jq -r '.id') + TIME_LOG_ID=$(echo "$RESPONSE_BODY" | jq -r '.timeLogId // empty') + + echo "" + echo "Time Session ID: $TIME_SESSION_ID" + if [ -n "$TIME_LOG_ID" ]; then + echo "Time Log ID: $TIME_LOG_ID" + fi +else + echo -e "${RED}✗ Clock In Failed (HTTP $HTTP_STATUS)${NC}" + echo "$RESPONSE_BODY" +fi + +echo "" +echo "---" +echo "" + +# Test 2: Get Active Time Session +echo -e "${YELLOW}Test 2: Get Active Time Session${NC}" +echo "GET ${APPOINTMENT_SERVICE_URL}/appointments/${APPOINTMENT_ID}/time-session" +echo "" + +sleep 2 # Wait 2 seconds to show elapsed time + +SESSION_RESPONSE=$(curl -s -w "\nHTTP_STATUS:%{http_code}" \ + -X GET "${APPOINTMENT_SERVICE_URL}/appointments/${APPOINTMENT_ID}/time-session" \ + -H "Authorization: Bearer ${JWT_TOKEN}" \ + -H "X-User-Subject: ${EMPLOYEE_ID}") + +HTTP_STATUS=$(echo "$SESSION_RESPONSE" | grep "HTTP_STATUS" | cut -d: -f2) +RESPONSE_BODY=$(echo "$SESSION_RESPONSE" | sed '/HTTP_STATUS/d') + +if [ "$HTTP_STATUS" == "200" ]; then + echo -e "${GREEN}✓ Active Session Found${NC}" + echo "$RESPONSE_BODY" | jq '.' + + ELAPSED_SECONDS=$(echo "$RESPONSE_BODY" | jq -r '.elapsedSeconds') + echo "" + echo "Elapsed Time: ${ELAPSED_SECONDS} seconds" +else + echo -e "${RED}✗ Failed to Get Session (HTTP $HTTP_STATUS)${NC}" + echo "$RESPONSE_BODY" +fi + +echo "" +echo "---" +echo "" + +# Test 3: Wait and show timer +echo -e "${YELLOW}Test 3: Live Timer Simulation${NC}" +echo "Waiting 5 seconds to simulate work..." +echo "" + +for i in {1..5}; do + sleep 1 + + # Poll for updated elapsed time + SESSION_RESPONSE=$(curl -s \ + -X GET "${APPOINTMENT_SERVICE_URL}/appointments/${APPOINTMENT_ID}/time-session" \ + -H "Authorization: Bearer ${JWT_TOKEN}" \ + -H "X-User-Subject: ${EMPLOYEE_ID}") + + ELAPSED=$(echo "$SESSION_RESPONSE" | jq -r '.elapsedSeconds // 0') + HOURS=$((ELAPSED / 3600)) + MINUTES=$(((ELAPSED % 3600) / 60)) + SECONDS=$((ELAPSED % 60)) + + printf "Timer: %02d:%02d:%02d\r" $HOURS $MINUTES $SECONDS +done + +echo "" +echo "" +echo "---" +echo "" + +# Test 4: Clock Out +echo -e "${YELLOW}Test 4: Clock Out${NC}" +echo "POST ${APPOINTMENT_SERVICE_URL}/appointments/${APPOINTMENT_ID}/clock-out" +echo "" + +CLOCK_OUT_RESPONSE=$(curl -s -w "\nHTTP_STATUS:%{http_code}" \ + -X POST "${APPOINTMENT_SERVICE_URL}/appointments/${APPOINTMENT_ID}/clock-out" \ + -H "Authorization: Bearer ${JWT_TOKEN}" \ + -H "X-User-Subject: ${EMPLOYEE_ID}" \ + -H "Content-Type: application/json") + +HTTP_STATUS=$(echo "$CLOCK_OUT_RESPONSE" | grep "HTTP_STATUS" | cut -d: -f2) +RESPONSE_BODY=$(echo "$CLOCK_OUT_RESPONSE" | sed '/HTTP_STATUS/d') + +if [ "$HTTP_STATUS" == "200" ]; then + echo -e "${GREEN}✓ Clock Out Successful${NC}" + echo "$RESPONSE_BODY" | jq '.' + + HOURS_WORKED=$(echo "$RESPONSE_BODY" | jq -r '.hoursWorked') + echo "" + echo "Total Hours Worked: $HOURS_WORKED" +else + echo -e "${RED}✗ Clock Out Failed (HTTP $HTTP_STATUS)${NC}" + echo "$RESPONSE_BODY" +fi + +echo "" +echo "---" +echo "" + +# Test 5: Verify Time Log in Time Logging Service +if [ -n "$TIME_LOG_ID" ]; then + echo -e "${YELLOW}Test 5: Verify Time Log in Time Logging Service${NC}" + echo "GET ${TIME_LOGGING_SERVICE_URL}/time-logs/${TIME_LOG_ID}" + echo "" + + TIME_LOG_RESPONSE=$(curl -s -w "\nHTTP_STATUS:%{http_code}" \ + -X GET "${TIME_LOGGING_SERVICE_URL}/time-logs/${TIME_LOG_ID}" \ + -H "Authorization: Bearer ${JWT_TOKEN}" \ + -H "X-User-Subject: ${EMPLOYEE_ID}") + + HTTP_STATUS=$(echo "$TIME_LOG_RESPONSE" | grep "HTTP_STATUS" | cut -d: -f2) + RESPONSE_BODY=$(echo "$TIME_LOG_RESPONSE" | sed '/HTTP_STATUS/d') + + if [ "$HTTP_STATUS" == "200" ]; then + echo -e "${GREEN}✓ Time Log Found${NC}" + echo "$RESPONSE_BODY" | jq '.' + else + echo -e "${RED}✗ Time Log Not Found (HTTP $HTTP_STATUS)${NC}" + echo "$RESPONSE_BODY" + fi +fi + +echo "" +echo "---" +echo "" + +# Test 6: Get Summary Stats +echo -e "${YELLOW}Test 6: Get Employee Time Summary${NC}" +echo "GET ${TIME_LOGGING_SERVICE_URL}/time-logs/summary?period=daily&date=$(date +%Y-%m-%d)" +echo "" + +SUMMARY_RESPONSE=$(curl -s -w "\nHTTP_STATUS:%{http_code}" \ + -X GET "${TIME_LOGGING_SERVICE_URL}/time-logs/summary?period=daily&date=$(date +%Y-%m-%d)" \ + -H "Authorization: Bearer ${JWT_TOKEN}" \ + -H "X-User-Subject: ${EMPLOYEE_ID}") + +HTTP_STATUS=$(echo "$SUMMARY_RESPONSE" | grep "HTTP_STATUS" | cut -d: -f2) +RESPONSE_BODY=$(echo "$SUMMARY_RESPONSE" | sed '/HTTP_STATUS/d') + +if [ "$HTTP_STATUS" == "200" ]; then + echo -e "${GREEN}✓ Summary Retrieved${NC}" + echo "$RESPONSE_BODY" | jq '.' +else + echo -e "${RED}✗ Summary Failed (HTTP $HTTP_STATUS)${NC}" + echo "$RESPONSE_BODY" +fi + +echo "" +echo -e "${YELLOW}========================================${NC}" +echo -e "${YELLOW}Test Complete${NC}" +echo -e "${YELLOW}========================================${NC}" +echo "" + +# Checklist +echo "Verification Checklist:" +echo "" +echo "[ ] Clock in created time log with 0 hours" +echo "[ ] Clock in changed appointment status to IN_PROGRESS" +echo "[ ] Active session returns current elapsed time" +echo "[ ] Timer increments every second" +echo "[ ] Clock out calculated correct hours" +echo "[ ] Clock out updated time log with actual hours" +echo "[ ] Clock out changed appointment status to COMPLETED" +echo "[ ] Summary shows today's total hours" +echo "" +echo "Next Steps:" +echo "1. Update JWT_TOKEN in this script with real token" +echo "2. Create a test appointment with valid ID" +echo "3. Run this script to test the full flow" +echo "4. Check database for TimeSession and TimeLog entries" +echo "5. Implement frontend UI components" From a75410429c10c5ee0fb82d2bd15633ef1e6173ad Mon Sep 17 00:00:00 2001 From: Akith-002 Date: Tue, 11 Nov 2025 22:06:22 +0530 Subject: [PATCH 13/16] feat: Add customer confirmation for appointment completion and update status transitions --- .../controller/AppointmentController.java | 11 ++ .../entity/AppointmentStatus.java | 13 +- .../InvalidStatusTransitionException.java | 4 + .../service/AppointmentService.java | 2 + .../AppointmentStateTransitionValidator.java | 121 ++++++++++++++++++ .../service/impl/AppointmentServiceImpl.java | 55 +++++++- 6 files changed, 199 insertions(+), 7 deletions(-) create mode 100644 appointment-service/src/main/java/com/techtorque/appointment_service/service/AppointmentStateTransitionValidator.java diff --git a/appointment-service/src/main/java/com/techtorque/appointment_service/controller/AppointmentController.java b/appointment-service/src/main/java/com/techtorque/appointment_service/controller/AppointmentController.java index 38d0c34..c6b8120 100644 --- a/appointment-service/src/main/java/com/techtorque/appointment_service/controller/AppointmentController.java +++ b/appointment-service/src/main/java/com/techtorque/appointment_service/controller/AppointmentController.java @@ -225,4 +225,15 @@ public ResponseEntity getActiveTimeSession( return ResponseEntity.ok(session); } + + @Operation(summary = "Customer confirms completion of appointment (customer only)") + @PostMapping("/{appointmentId}/confirm-completion") + @PreAuthorize("hasRole('CUSTOMER')") + public ResponseEntity confirmCompletion( + @PathVariable String appointmentId, + @RequestHeader("X-User-Subject") String customerId) { + + AppointmentResponseDto updated = appointmentService.confirmCompletion(appointmentId, customerId); + return ResponseEntity.ok(updated); + } } diff --git a/appointment-service/src/main/java/com/techtorque/appointment_service/entity/AppointmentStatus.java b/appointment-service/src/main/java/com/techtorque/appointment_service/entity/AppointmentStatus.java index dbb30da..ca2dfdf 100644 --- a/appointment-service/src/main/java/com/techtorque/appointment_service/entity/AppointmentStatus.java +++ b/appointment-service/src/main/java/com/techtorque/appointment_service/entity/AppointmentStatus.java @@ -1,10 +1,11 @@ package com.techtorque.appointment_service.entity; public enum AppointmentStatus { - PENDING, - CONFIRMED, - IN_PROGRESS, - COMPLETED, - CANCELLED, - NO_SHOW + PENDING, // Initial state after booking + CONFIRMED, // After admin assigns employee(s) + IN_PROGRESS, // After employee accepts and starts work + COMPLETED, // After employee marks work as complete + CUSTOMER_CONFIRMED, // After customer confirms completion (final) + CANCELLED, // Cancelled by customer or admin (final) + NO_SHOW // Customer didn't show up (final) } \ No newline at end of file diff --git a/appointment-service/src/main/java/com/techtorque/appointment_service/exception/InvalidStatusTransitionException.java b/appointment-service/src/main/java/com/techtorque/appointment_service/exception/InvalidStatusTransitionException.java index e728ed8..e75e0c0 100644 --- a/appointment-service/src/main/java/com/techtorque/appointment_service/exception/InvalidStatusTransitionException.java +++ b/appointment-service/src/main/java/com/techtorque/appointment_service/exception/InvalidStatusTransitionException.java @@ -11,4 +11,8 @@ public InvalidStatusTransitionException(String message) { public InvalidStatusTransitionException(AppointmentStatus currentStatus, AppointmentStatus newStatus) { super(String.format("Cannot transition from status '%s' to '%s'", currentStatus, newStatus)); } + + public InvalidStatusTransitionException(AppointmentStatus currentStatus, AppointmentStatus newStatus, String reason) { + super(String.format("Cannot transition from status '%s' to '%s': %s", currentStatus, newStatus, reason)); + } } diff --git a/appointment-service/src/main/java/com/techtorque/appointment_service/service/AppointmentService.java b/appointment-service/src/main/java/com/techtorque/appointment_service/service/AppointmentService.java index e907ca7..750833b 100644 --- a/appointment-service/src/main/java/com/techtorque/appointment_service/service/AppointmentService.java +++ b/appointment-service/src/main/java/com/techtorque/appointment_service/service/AppointmentService.java @@ -37,6 +37,8 @@ List getAppointmentsWithFilters( AppointmentResponseDto completeWork(String appointmentId, String employeeId); + AppointmentResponseDto confirmCompletion(String appointmentId, String customerId); + // Time tracking methods TimeSessionResponse clockIn(String appointmentId, String employeeId); diff --git a/appointment-service/src/main/java/com/techtorque/appointment_service/service/AppointmentStateTransitionValidator.java b/appointment-service/src/main/java/com/techtorque/appointment_service/service/AppointmentStateTransitionValidator.java new file mode 100644 index 0000000..7d200b4 --- /dev/null +++ b/appointment-service/src/main/java/com/techtorque/appointment_service/service/AppointmentStateTransitionValidator.java @@ -0,0 +1,121 @@ +package com.techtorque.appointment_service.service; + +import com.techtorque.appointment_service.entity.AppointmentStatus; +import com.techtorque.appointment_service.exception.InvalidStatusTransitionException; +import org.springframework.stereotype.Component; + +import java.util.*; + +/** + * Validates appointment state transitions and enforces business rules. + * Implements a strict state machine for appointment lifecycle management. + */ +@Component +public class AppointmentStateTransitionValidator { + + /** + * Define valid state transitions and the roles that can perform them + */ + private static final Map>> VALID_TRANSITIONS = new HashMap<>(); + + static { + // PENDING state transitions + Map> pendingTransitions = new HashMap<>(); + pendingTransitions.put(AppointmentStatus.CONFIRMED, Set.of("ADMIN", "SUPER_ADMIN")); // Admin assigns employee + pendingTransitions.put(AppointmentStatus.CANCELLED, Set.of("CUSTOMER", "ADMIN", "SUPER_ADMIN")); // Customer or admin cancels + VALID_TRANSITIONS.put(AppointmentStatus.PENDING, pendingTransitions); + + // CONFIRMED state transitions + Map> confirmedTransitions = new HashMap<>(); + confirmedTransitions.put(AppointmentStatus.IN_PROGRESS, Set.of("EMPLOYEE", "ADMIN", "SUPER_ADMIN")); // Employee starts work + confirmedTransitions.put(AppointmentStatus.NO_SHOW, Set.of("ADMIN", "SUPER_ADMIN")); // Admin marks as no-show + confirmedTransitions.put(AppointmentStatus.CANCELLED, Set.of("ADMIN", "SUPER_ADMIN")); // Admin cancels + VALID_TRANSITIONS.put(AppointmentStatus.CONFIRMED, confirmedTransitions); + + // IN_PROGRESS state transitions + Map> inProgressTransitions = new HashMap<>(); + inProgressTransitions.put(AppointmentStatus.COMPLETED, Set.of("EMPLOYEE", "ADMIN", "SUPER_ADMIN")); // Employee marks complete + inProgressTransitions.put(AppointmentStatus.CANCELLED, Set.of("ADMIN", "SUPER_ADMIN")); // Admin cancels + VALID_TRANSITIONS.put(AppointmentStatus.IN_PROGRESS, inProgressTransitions); + + // COMPLETED state transitions + Map> completedTransitions = new HashMap<>(); + completedTransitions.put(AppointmentStatus.CUSTOMER_CONFIRMED, Set.of("CUSTOMER")); // Customer confirms completion + VALID_TRANSITIONS.put(AppointmentStatus.COMPLETED, completedTransitions); + + // Terminal states - no transitions allowed + VALID_TRANSITIONS.put(AppointmentStatus.CUSTOMER_CONFIRMED, new HashMap<>()); // Terminal + VALID_TRANSITIONS.put(AppointmentStatus.CANCELLED, new HashMap<>()); // Terminal + VALID_TRANSITIONS.put(AppointmentStatus.NO_SHOW, new HashMap<>()); // Terminal + } + + /** + * Validates if a state transition is allowed for a given user role + * + * @param currentStatus Current appointment status + * @param newStatus Desired new status + * @param userRole Role of the user attempting the transition + * @throws InvalidStatusTransitionException if transition is not allowed + */ + public void validateTransition(AppointmentStatus currentStatus, AppointmentStatus newStatus, String userRole) { + // Check if current status exists in valid transitions + if (!VALID_TRANSITIONS.containsKey(currentStatus)) { + throw new InvalidStatusTransitionException( + currentStatus, newStatus, + "Current status '" + currentStatus + "' is not recognized in state machine"); + } + + // Get allowed transitions from current status + Map> allowedTransitions = VALID_TRANSITIONS.get(currentStatus); + + // Check if the new status is in the allowed transitions + if (!allowedTransitions.containsKey(newStatus)) { + throw new InvalidStatusTransitionException( + currentStatus, newStatus, + "Transition from '" + currentStatus + "' to '" + newStatus + "' is not allowed"); + } + + // Check if the user role is authorized for this transition + Set allowedRoles = allowedTransitions.get(newStatus); + if (!allowedRoles.contains(userRole)) { + throw new InvalidStatusTransitionException( + currentStatus, newStatus, + "User role '" + userRole + "' is not authorized to transition from '" + currentStatus + "' to '" + newStatus + "'. " + + "Allowed roles: " + String.join(", ", allowedRoles)); + } + } + + /** + * Gets all valid target states from a given current state + * + * @param currentStatus Current appointment status + * @return Map of valid target states and allowed roles + */ + public Map> getValidTransitions(AppointmentStatus currentStatus) { + return VALID_TRANSITIONS.getOrDefault(currentStatus, new HashMap<>()); + } + + /** + * Checks if a status is a terminal state (no transitions allowed) + * + * @param status Appointment status + * @return true if status is terminal + */ + public boolean isTerminalState(AppointmentStatus status) { + return getValidTransitions(status).isEmpty(); + } + + /** + * Gets allowed roles for a specific transition + * + * @param currentStatus Current status + * @param newStatus Target status + * @return Set of allowed roles, empty set if transition not allowed + */ + public Set getAllowedRoles(AppointmentStatus currentStatus, AppointmentStatus newStatus) { + return VALID_TRANSITIONS + .getOrDefault(currentStatus, new HashMap<>()) + .getOrDefault(newStatus, new HashSet<>()); + } +} + diff --git a/appointment-service/src/main/java/com/techtorque/appointment_service/service/impl/AppointmentServiceImpl.java b/appointment-service/src/main/java/com/techtorque/appointment_service/service/impl/AppointmentServiceImpl.java index e8d49ab..c337d91 100644 --- a/appointment-service/src/main/java/com/techtorque/appointment_service/service/impl/AppointmentServiceImpl.java +++ b/appointment-service/src/main/java/com/techtorque/appointment_service/service/impl/AppointmentServiceImpl.java @@ -6,6 +6,7 @@ import com.techtorque.appointment_service.exception.*; import com.techtorque.appointment_service.repository.*; import com.techtorque.appointment_service.service.AppointmentService; +import com.techtorque.appointment_service.service.AppointmentStateTransitionValidator; import com.techtorque.appointment_service.service.ServiceTypeService; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -27,6 +28,7 @@ public class AppointmentServiceImpl implements AppointmentService { private final TimeSessionRepository timeSessionRepository; private final com.techtorque.appointment_service.service.NotificationClient notificationClient; private final com.techtorque.appointment_service.client.TimeLoggingClient timeLoggingClient; + private final AppointmentStateTransitionValidator stateTransitionValidator; private static final int SLOT_INTERVAL_MINUTES = 30; @@ -38,7 +40,8 @@ public AppointmentServiceImpl( HolidayRepository holidayRepository, TimeSessionRepository timeSessionRepository, com.techtorque.appointment_service.service.NotificationClient notificationClient, - com.techtorque.appointment_service.client.TimeLoggingClient timeLoggingClient) { + com.techtorque.appointment_service.client.TimeLoggingClient timeLoggingClient, + AppointmentStateTransitionValidator stateTransitionValidator) { this.appointmentRepository = appointmentRepository; this.serviceTypeService = serviceTypeService; this.serviceBayRepository = serviceBayRepository; @@ -47,6 +50,7 @@ public AppointmentServiceImpl( this.timeSessionRepository = timeSessionRepository; this.notificationClient = notificationClient; this.timeLoggingClient = timeLoggingClient; + this.stateTransitionValidator = stateTransitionValidator; } @Override @@ -946,4 +950,53 @@ private TimeSessionResponse convertToTimeSessionResponse(TimeSession session) { return response; } + + @Override + public AppointmentResponseDto confirmCompletion(String appointmentId, String customerId) { + log.info("Customer {} confirming completion for appointment {}", customerId, appointmentId); + + Appointment appointment = appointmentRepository.findById(appointmentId) + .orElseThrow(() -> new AppointmentNotFoundException("Appointment not found with ID: " + appointmentId)); + + // Verify the customer owns this appointment + if (!appointment.getCustomerId().equals(customerId)) { + throw new UnauthorizedAccessException("You do not have permission to confirm this appointment"); + } + + // Verify appointment is in COMPLETED status + if (appointment.getStatus() != AppointmentStatus.COMPLETED) { + throw new IllegalStateException( + "Can only confirm completion for COMPLETED appointments. Current status: " + appointment.getStatus()); + } + + // Update appointment status to CUSTOMER_CONFIRMED + appointment.setStatus(AppointmentStatus.CUSTOMER_CONFIRMED); + Appointment savedAppointment = appointmentRepository.save(appointment); + + // Notify customer confirmation + notificationClient.sendAppointmentNotification( + customerId, + "SUCCESS", + "Appointment Confirmed Complete", + String.format("You have confirmed completion of your %s appointment (Confirmation: %s). Thank you!", + appointment.getServiceType(), + appointment.getConfirmationNumber()), + appointmentId + ); + + // Notify assigned employees that customer confirmed + for (String employeeId : appointment.getAssignedEmployeeIds()) { + notificationClient.sendAppointmentNotification( + employeeId, + "SUCCESS", + "Customer Confirmed Completion", + String.format("Customer has confirmed completion of appointment %s", + appointment.getConfirmationNumber()), + appointmentId + ); + } + + log.info("Appointment {} moved to CUSTOMER_CONFIRMED status", appointmentId); + return convertToDto(savedAppointment); + } } From ce6126de7501ba67ab4d66f27b85a0d15c760250 Mon Sep 17 00:00:00 2001 From: Akith-002 Date: Tue, 11 Nov 2025 22:07:13 +0530 Subject: [PATCH 14/16] refactor: Remove unused imports and clean up appointment service code --- .../service/impl/AppointmentServiceImpl.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/appointment-service/src/main/java/com/techtorque/appointment_service/service/impl/AppointmentServiceImpl.java b/appointment-service/src/main/java/com/techtorque/appointment_service/service/impl/AppointmentServiceImpl.java index c337d91..b92a1d9 100644 --- a/appointment-service/src/main/java/com/techtorque/appointment_service/service/impl/AppointmentServiceImpl.java +++ b/appointment-service/src/main/java/com/techtorque/appointment_service/service/impl/AppointmentServiceImpl.java @@ -7,7 +7,6 @@ import com.techtorque.appointment_service.repository.*; import com.techtorque.appointment_service.service.AppointmentService; import com.techtorque.appointment_service.service.AppointmentStateTransitionValidator; -import com.techtorque.appointment_service.service.ServiceTypeService; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -28,8 +27,8 @@ public class AppointmentServiceImpl implements AppointmentService { private final TimeSessionRepository timeSessionRepository; private final com.techtorque.appointment_service.service.NotificationClient notificationClient; private final com.techtorque.appointment_service.client.TimeLoggingClient timeLoggingClient; + private final com.techtorque.appointment_service.AppointmentStateTransitionValidator stateTransitionValidator; private final AppointmentStateTransitionValidator stateTransitionValidator; - private static final int SLOT_INTERVAL_MINUTES = 30; public AppointmentServiceImpl( @@ -41,8 +40,8 @@ public AppointmentServiceImpl( TimeSessionRepository timeSessionRepository, com.techtorque.appointment_service.service.NotificationClient notificationClient, com.techtorque.appointment_service.client.TimeLoggingClient timeLoggingClient, + com.techtorque.appointment_service.AppointmentStateTransitionValidator stateTransitionValidator) { AppointmentStateTransitionValidator stateTransitionValidator) { - this.appointmentRepository = appointmentRepository; this.serviceTypeService = serviceTypeService; this.serviceBayRepository = serviceBayRepository; this.businessHoursRepository = businessHoursRepository; From 735f6f6532dce941ccddd2f44df35a3b4369849e Mon Sep 17 00:00:00 2001 From: Akith-002 Date: Tue, 11 Nov 2025 22:45:20 +0530 Subject: [PATCH 15/16] feat: Enhance appointment management by allowing CUSTOMER_CONFIRMED status and updating cancellation logic --- .../service/impl/AppointmentServiceImpl.java | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/appointment-service/src/main/java/com/techtorque/appointment_service/service/impl/AppointmentServiceImpl.java b/appointment-service/src/main/java/com/techtorque/appointment_service/service/impl/AppointmentServiceImpl.java index b92a1d9..ef975d6 100644 --- a/appointment-service/src/main/java/com/techtorque/appointment_service/service/impl/AppointmentServiceImpl.java +++ b/appointment-service/src/main/java/com/techtorque/appointment_service/service/impl/AppointmentServiceImpl.java @@ -6,6 +6,7 @@ import com.techtorque.appointment_service.exception.*; import com.techtorque.appointment_service.repository.*; import com.techtorque.appointment_service.service.AppointmentService; +import com.techtorque.appointment_service.service.ServiceTypeService; import com.techtorque.appointment_service.service.AppointmentStateTransitionValidator; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -27,7 +28,6 @@ public class AppointmentServiceImpl implements AppointmentService { private final TimeSessionRepository timeSessionRepository; private final com.techtorque.appointment_service.service.NotificationClient notificationClient; private final com.techtorque.appointment_service.client.TimeLoggingClient timeLoggingClient; - private final com.techtorque.appointment_service.AppointmentStateTransitionValidator stateTransitionValidator; private final AppointmentStateTransitionValidator stateTransitionValidator; private static final int SLOT_INTERVAL_MINUTES = 30; @@ -40,8 +40,8 @@ public AppointmentServiceImpl( TimeSessionRepository timeSessionRepository, com.techtorque.appointment_service.service.NotificationClient notificationClient, com.techtorque.appointment_service.client.TimeLoggingClient timeLoggingClient, - com.techtorque.appointment_service.AppointmentStateTransitionValidator stateTransitionValidator) { AppointmentStateTransitionValidator stateTransitionValidator) { + this.appointmentRepository = appointmentRepository; // assign required repository this.serviceTypeService = serviceTypeService; this.serviceBayRepository = serviceBayRepository; this.businessHoursRepository = businessHoursRepository; @@ -181,7 +181,8 @@ public AppointmentResponseDto updateAppointment(String appointmentId, Appointmen if (appointment.getStatus() != AppointmentStatus.PENDING && appointment.getStatus() != AppointmentStatus.CONFIRMED) { - throw new InvalidStatusTransitionException("Cannot update appointment with status: " + appointment.getStatus()); + throw new InvalidStatusTransitionException("Cannot update appointment with status: " + appointment.getStatus() + + ". Once work has started (IN_PROGRESS status), appointments cannot be rescheduled."); } if (dto.getRequestedDateTime() != null) { @@ -221,6 +222,12 @@ public void cancelAppointment(String appointmentId, String userId, String userRo if (userRoles.contains("CUSTOMER") && !userRoles.contains("EMPLOYEE") && !userRoles.contains("ADMIN")) { appointment = appointmentRepository.findByIdAndCustomerId(appointmentId, userId) .orElseThrow(() -> new AppointmentNotFoundException(appointmentId, userId)); + + // Customers cannot cancel appointments that are IN_PROGRESS or beyond + if (appointment.getStatus() == AppointmentStatus.IN_PROGRESS) { + throw new InvalidStatusTransitionException( + "Cannot cancel an appointment that is currently in progress. Please contact support for assistance."); + } } else { // Employees and admins can cancel any appointment appointment = appointmentRepository.findById(appointmentId) From c8333c645f40383ff2dc8f1b3b28190d27778c85 Mon Sep 17 00:00:00 2001 From: RandithaK Date: Tue, 11 Nov 2025 23:56:28 +0530 Subject: [PATCH 16/16] fix: Restrict pull request triggers to specific branches for build and test workflow --- .github/workflows/buildtest.yaml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/buildtest.yaml b/.github/workflows/buildtest.yaml index e6bce79..0d8e18a 100644 --- a/.github/workflows/buildtest.yaml +++ b/.github/workflows/buildtest.yaml @@ -1,12 +1,11 @@ name: Build and Test Appointment Service on: - push: - branches: - - '**' pull_request: branches: - - '**' + - main + - dev + - devOps jobs: build-test: