diff --git a/.github/workflows/buildtest.yaml b/.github/workflows/buildtest.yaml index 132b427..72ab4c0 100644 --- a/.github/workflows/buildtest.yaml +++ b/.github/workflows/buildtest.yaml @@ -1,12 +1,11 @@ name: Build and Test Project Service on: - push: - branches: - - '**' pull_request: branches: - - '**' + - main + - dev + - devOps jobs: build-test: diff --git a/project-service/src/main/java/com/techtorque/project_service/client/AppointmentClient.java b/project-service/src/main/java/com/techtorque/project_service/client/AppointmentClient.java new file mode 100644 index 0000000..83326e4 --- /dev/null +++ b/project-service/src/main/java/com/techtorque/project_service/client/AppointmentClient.java @@ -0,0 +1,76 @@ +package com.techtorque.project_service.client; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.client.HttpClientErrorException; + +/** + * Client for inter-service communication with Appointment Service + */ +@Component +@Slf4j +public class AppointmentClient { + + private final RestTemplate restTemplate; + private final String appointmentServiceUrl; + + public AppointmentClient( + RestTemplate restTemplate, + @Value("${services.appointment.url:http://localhost:8083}") String appointmentServiceUrl) { + this.restTemplate = restTemplate; + this.appointmentServiceUrl = appointmentServiceUrl; + } + + /** + * Cancel an appointment (used when project is rejected) + */ + public void cancelAppointment(String appointmentId, String adminId) { + try { + String url = appointmentServiceUrl + "/api/appointments/" + appointmentId; + + HttpHeaders headers = new HttpHeaders(); + headers.set("X-User-Subject", adminId); + headers.set("X-User-Roles", "ADMIN"); + + HttpEntity request = new HttpEntity<>(headers); + + restTemplate.exchange(url, HttpMethod.DELETE, request, Void.class); + + log.info("Successfully cancelled appointment {} via Appointment Service", appointmentId); + } catch (HttpClientErrorException e) { + log.error("Failed to cancel appointment {}: {}", appointmentId, e.getMessage()); + // Don't throw - project rejection should still succeed even if appointment cancellation fails + } catch (Exception e) { + log.error("Error communicating with Appointment Service: {}", e.getMessage()); + } + } + + /** + * Update appointment status to CONFIRMED (used when project is approved) + */ + public void confirmAppointment(String appointmentId, String adminId) { + try { + String url = appointmentServiceUrl + "/api/appointments/" + appointmentId + "/status"; + + HttpHeaders headers = new HttpHeaders(); + headers.set("X-User-Subject", adminId); + headers.set("X-User-Roles", "ADMIN"); + headers.setContentType(MediaType.APPLICATION_JSON); + + String body = "{\"newStatus\":\"CONFIRMED\"}"; + HttpEntity request = new HttpEntity<>(body, headers); + + restTemplate.exchange(url, HttpMethod.PATCH, request, String.class); + + log.info("Successfully confirmed appointment {} via Appointment Service", appointmentId); + } catch (HttpClientErrorException e) { + log.error("Failed to confirm appointment {}: {}", appointmentId, e.getMessage()); + // Don't throw - project approval should still succeed even if appointment confirmation fails + } catch (Exception e) { + log.error("Error communicating with Appointment Service: {}", e.getMessage()); + } + } +} diff --git a/project-service/src/main/java/com/techtorque/project_service/client/NotificationClient.java b/project-service/src/main/java/com/techtorque/project_service/client/NotificationClient.java new file mode 100644 index 0000000..4be0858 --- /dev/null +++ b/project-service/src/main/java/com/techtorque/project_service/client/NotificationClient.java @@ -0,0 +1,53 @@ +package com.techtorque.project_service.client; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.client.HttpClientErrorException; + +/** + * Client for inter-service communication with Notification Service + */ +@Component +@Slf4j +public class NotificationClient { + + private final RestTemplate restTemplate; + private final String notificationServiceUrl; + + public NotificationClient( + RestTemplate restTemplate, + @Value("${services.notification.url:http://localhost:8088}") String notificationServiceUrl) { + this.restTemplate = restTemplate; + this.notificationServiceUrl = notificationServiceUrl; + } + + /** + * Send project notification to user + */ + public void sendProjectNotification(String userId, String type, String title, String message, String projectId) { + try { + String url = notificationServiceUrl + "/api/notifications/project"; + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + String body = String.format( + "{\"userId\":\"%s\",\"type\":\"%s\",\"title\":\"%s\",\"message\":\"%s\",\"referenceId\":\"%s\",\"referenceType\":\"PROJECT\"}", + userId, type, title, message, projectId); + + HttpEntity request = new HttpEntity<>(body, headers); + + restTemplate.postForEntity(url, request, String.class); + + log.info("Successfully sent project notification to user {}", userId); + } catch (HttpClientErrorException e) { + log.error("Failed to send notification to user {}: {}", userId, e.getMessage()); + // Don't throw - project operations should still succeed even if notification fails + } catch (Exception e) { + log.error("Error communicating with Notification Service: {}", e.getMessage()); + } + } +} diff --git a/project-service/src/main/java/com/techtorque/project_service/config/DataSeeder.java b/project-service/src/main/java/com/techtorque/project_service/config/DataSeeder.java index 42cec4b..bfa39bb 100644 --- a/project-service/src/main/java/com/techtorque/project_service/config/DataSeeder.java +++ b/project-service/src/main/java/com/techtorque/project_service/config/DataSeeder.java @@ -111,6 +111,7 @@ private void seedProjects() { Project project1 = Project.builder() .customerId(CUSTOMER_1_ID) .vehicleId("VEH-001") + .projectType("Performance") .description("Install custom exhaust system and performance tuning") .budget(new BigDecimal("5000.00")) .status(ProjectStatus.APPROVED) @@ -121,6 +122,7 @@ private void seedProjects() { Project project2 = Project.builder() .customerId(CUSTOMER_2_ID) .vehicleId("VEH-002") + .projectType("Interior") .description("Full interior leather upholstery replacement") .budget(new BigDecimal("3000.00")) .status(ProjectStatus.QUOTED) @@ -131,6 +133,7 @@ private void seedProjects() { Project project3 = Project.builder() .customerId(CUSTOMER_1_ID) .vehicleId("VEH-001") + .projectType("Body Work") .description("Custom body kit installation and paint job") .budget(new BigDecimal("8000.00")) .status(ProjectStatus.IN_PROGRESS) diff --git a/project-service/src/main/java/com/techtorque/project_service/config/RestTemplateConfig.java b/project-service/src/main/java/com/techtorque/project_service/config/RestTemplateConfig.java new file mode 100644 index 0000000..52bdc45 --- /dev/null +++ b/project-service/src/main/java/com/techtorque/project_service/config/RestTemplateConfig.java @@ -0,0 +1,14 @@ +package com.techtorque.project_service.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class RestTemplateConfig { + + @Bean + public RestTemplate restTemplate() { + return new RestTemplate(); + } +} diff --git a/project-service/src/main/java/com/techtorque/project_service/controller/ProjectController.java b/project-service/src/main/java/com/techtorque/project_service/controller/ProjectController.java index 8c4a451..3f5c7a4 100644 --- a/project-service/src/main/java/com/techtorque/project_service/controller/ProjectController.java +++ b/project-service/src/main/java/com/techtorque/project_service/controller/ProjectController.java @@ -145,13 +145,42 @@ public ResponseEntity listAllProjects() { return ResponseEntity.ok(ApiResponse.success("All projects retrieved successfully", response)); } + @Operation(summary = "Approve a custom project request (admin only)") + @PostMapping("/{projectId}/approve") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity approveProject( + @PathVariable String projectId, + @RequestHeader("X-User-Subject") String adminId) { + + Project project = projectService.approveProject(projectId, adminId); + ProjectResponseDto response = mapToResponseDto(project); + + return ResponseEntity.ok(ApiResponse.success("Project approved successfully", response)); + } + + @Operation(summary = "Reject a custom project request (admin only)") + @PostMapping("/{projectId}/admin/reject") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity rejectProject( + @PathVariable String projectId, + @RequestParam(required = false) String reason, + @RequestHeader("X-User-Subject") String adminId) { + + Project project = projectService.rejectProject(projectId, reason, adminId); + ProjectResponseDto response = mapToResponseDto(project); + + return ResponseEntity.ok(ApiResponse.success("Project rejected successfully", response)); + } + // Helper method to map Entity to DTO private ProjectResponseDto mapToResponseDto(Project project) { return ProjectResponseDto.builder() .id(project.getId()) .customerId(project.getCustomerId()) .vehicleId(project.getVehicleId()) + .projectType(project.getProjectType()) .description(project.getDescription()) + .desiredCompletionDate(project.getDesiredCompletionDate()) .budget(project.getBudget()) .status(project.getStatus()) .progress(project.getProgress()) diff --git a/project-service/src/main/java/com/techtorque/project_service/dto/request/ProjectRequestDto.java b/project-service/src/main/java/com/techtorque/project_service/dto/request/ProjectRequestDto.java index 4e0273a..3cf6a67 100644 --- a/project-service/src/main/java/com/techtorque/project_service/dto/request/ProjectRequestDto.java +++ b/project-service/src/main/java/com/techtorque/project_service/dto/request/ProjectRequestDto.java @@ -9,6 +9,7 @@ import lombok.NoArgsConstructor; import java.math.BigDecimal; +import java.time.LocalDate; @Data @Builder @@ -19,9 +20,14 @@ public class ProjectRequestDto { @NotBlank(message = "Vehicle ID is required") private String vehicleId; + @NotBlank(message = "Project type is required") + private String projectType; + @NotBlank(message = "Description is required") @Size(min = 10, max = 2000, message = "Description must be between 10 and 2000 characters") private String description; + private String desiredCompletionDate; + private BigDecimal budget; } diff --git a/project-service/src/main/java/com/techtorque/project_service/dto/response/ProjectResponseDto.java b/project-service/src/main/java/com/techtorque/project_service/dto/response/ProjectResponseDto.java index 21ae1f4..3fec8ae 100644 --- a/project-service/src/main/java/com/techtorque/project_service/dto/response/ProjectResponseDto.java +++ b/project-service/src/main/java/com/techtorque/project_service/dto/response/ProjectResponseDto.java @@ -17,7 +17,9 @@ public class ProjectResponseDto { private String id; private String customerId; private String vehicleId; + private String projectType; private String description; + private String desiredCompletionDate; private BigDecimal budget; private ProjectStatus status; private int progress; diff --git a/project-service/src/main/java/com/techtorque/project_service/entity/Project.java b/project-service/src/main/java/com/techtorque/project_service/entity/Project.java index 7e54c6d..be5116f 100644 --- a/project-service/src/main/java/com/techtorque/project_service/entity/Project.java +++ b/project-service/src/main/java/com/techtorque/project_service/entity/Project.java @@ -5,6 +5,7 @@ import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.UpdateTimestamp; import java.math.BigDecimal; +import java.time.LocalDate; import java.time.LocalDateTime; @Entity @@ -24,10 +25,17 @@ public class Project { @Column(nullable = false) private String vehicleId; + private String appointmentId; // Link to appointment if project was created from appointment booking + + @Column(nullable = false) + private String projectType; + @Lob @Column(nullable = false) private String description; + private String desiredCompletionDate; + private BigDecimal budget; // Use BigDecimal for currency @Enumerated(EnumType.STRING) diff --git a/project-service/src/main/java/com/techtorque/project_service/entity/ProjectStatus.java b/project-service/src/main/java/com/techtorque/project_service/entity/ProjectStatus.java index 6eb2339..6b386cc 100644 --- a/project-service/src/main/java/com/techtorque/project_service/entity/ProjectStatus.java +++ b/project-service/src/main/java/com/techtorque/project_service/entity/ProjectStatus.java @@ -2,6 +2,7 @@ public enum ProjectStatus { REQUESTED, + PENDING_ADMIN_REVIEW, // Custom project awaiting admin approval QUOTED, APPROVED, IN_PROGRESS, diff --git a/project-service/src/main/java/com/techtorque/project_service/service/ProjectService.java b/project-service/src/main/java/com/techtorque/project_service/service/ProjectService.java index 49b98ac..24484df 100644 --- a/project-service/src/main/java/com/techtorque/project_service/service/ProjectService.java +++ b/project-service/src/main/java/com/techtorque/project_service/service/ProjectService.java @@ -25,4 +25,8 @@ public interface ProjectService { Project updateProgress(String projectId, ProgressUpdateDto dto); List getAllProjects(); + + Project approveProject(String projectId, String adminId); + + Project rejectProject(String projectId, String reason, String adminId); } \ No newline at end of file diff --git a/project-service/src/main/java/com/techtorque/project_service/service/impl/ProjectServiceImpl.java b/project-service/src/main/java/com/techtorque/project_service/service/impl/ProjectServiceImpl.java index 36d14a9..b72889a 100644 --- a/project-service/src/main/java/com/techtorque/project_service/service/impl/ProjectServiceImpl.java +++ b/project-service/src/main/java/com/techtorque/project_service/service/impl/ProjectServiceImpl.java @@ -1,5 +1,7 @@ package com.techtorque.project_service.service.impl; +import com.techtorque.project_service.client.AppointmentClient; +import com.techtorque.project_service.client.NotificationClient; import com.techtorque.project_service.dto.request.ProgressUpdateDto; import com.techtorque.project_service.dto.request.ProjectRequestDto; import com.techtorque.project_service.dto.response.QuoteDto; @@ -23,9 +25,16 @@ public class ProjectServiceImpl implements ProjectService { private final ProjectRepository projectRepository; + private final AppointmentClient appointmentClient; + private final NotificationClient notificationClient; - public ProjectServiceImpl(ProjectRepository projectRepository) { + public ProjectServiceImpl( + ProjectRepository projectRepository, + AppointmentClient appointmentClient, + NotificationClient notificationClient) { this.projectRepository = projectRepository; + this.appointmentClient = appointmentClient; + this.notificationClient = notificationClient; } @Override @@ -36,7 +45,9 @@ public Project requestNewProject(ProjectRequestDto dto, String customerId) { Project newProject = Project.builder() .customerId(customerId) .vehicleId(dto.getVehicleId()) + .projectType(dto.getProjectType()) .description(dto.getDescription()) + .desiredCompletionDate(dto.getDesiredCompletionDate()) .budget(dto.getBudget()) .status(ProjectStatus.REQUESTED) .progress(0) @@ -209,4 +220,88 @@ public List getAllProjects() { log.info("Fetching all projects"); return projectRepository.findAll(); } + + @Override + public Project approveProject(String projectId, String adminId) { + log.info("Admin {} approving project {}", adminId, projectId); + + Project project = projectRepository.findById(projectId) + .orElseThrow(() -> new ProjectNotFoundException("Project not found: " + projectId)); + + // Validate project is in correct state for approval + if (project.getStatus() != ProjectStatus.REQUESTED && + project.getStatus() != ProjectStatus.PENDING_ADMIN_REVIEW) { + throw new InvalidProjectOperationException( + "Project must be in REQUESTED or PENDING_ADMIN_REVIEW status to be approved. Current status: " + project.getStatus()); + } + + // Approve the project + project.setStatus(ProjectStatus.APPROVED); + + Project savedProject = projectRepository.save(project); + log.info("Successfully approved project {}", projectId); + + // Send notification to customer that project was approved + notificationClient.sendProjectNotification( + project.getCustomerId(), + "SUCCESS", + "Project Approved", + String.format("Your custom project '%s' has been approved! We will proceed with the work as discussed.", + project.getProjectType()), + projectId + ); + + // If project has linked appointment, confirm it + if (project.getAppointmentId() != null && !project.getAppointmentId().isEmpty()) { + log.info("Project {} has linked appointment {}, confirming it", projectId, project.getAppointmentId()); + appointmentClient.confirmAppointment(project.getAppointmentId(), adminId); + } + + return savedProject; + } + + @Override + public Project rejectProject(String projectId, String reason, String adminId) { + log.info("Admin {} rejecting project {} with reason: {}", adminId, projectId, reason); + + Project project = projectRepository.findById(projectId) + .orElseThrow(() -> new ProjectNotFoundException("Project not found: " + projectId)); + + // Validate project is in correct state for rejection + if (project.getStatus() != ProjectStatus.REQUESTED && + project.getStatus() != ProjectStatus.PENDING_ADMIN_REVIEW && + project.getStatus() != ProjectStatus.QUOTED) { + throw new InvalidProjectOperationException( + "Project must be in REQUESTED, PENDING_ADMIN_REVIEW, or QUOTED status to be rejected. Current status: " + project.getStatus()); + } + + // Reject the project + project.setStatus(ProjectStatus.REJECTED); + + Project savedProject = projectRepository.save(project); + log.info("Successfully rejected project {}", projectId); + + // Send notification to customer about rejection with reason + String rejectionMessage = String.format( + "Your custom project '%s' has been reviewed and unfortunately cannot be accepted at this time.%s", + project.getProjectType(), + reason != null && !reason.isEmpty() ? " Reason: " + reason : "" + ); + + notificationClient.sendProjectNotification( + project.getCustomerId(), + "WARNING", + "Project Rejected", + rejectionMessage, + projectId + ); + + // If project has linked appointment, cancel it and free the timeslot + if (project.getAppointmentId() != null && !project.getAppointmentId().isEmpty()) { + log.info("Project {} has linked appointment {}, cancelling it", projectId, project.getAppointmentId()); + appointmentClient.cancelAppointment(project.getAppointmentId(), adminId); + } + + return savedProject; + } } \ No newline at end of file diff --git a/project-service/src/main/resources/application.properties b/project-service/src/main/resources/application.properties index bbea78b..82b06d1 100644 --- a/project-service/src/main/resources/application.properties +++ b/project-service/src/main/resources/application.properties @@ -23,5 +23,9 @@ spring.servlet.multipart.max-file-size=10MB spring.servlet.multipart.max-request-size=50MB file.upload-dir=uploads/service-photos +# Inter-service Communication URLs +services.appointment.url=${APPOINTMENT_SERVICE_URL:http://localhost:8083} +services.notification.url=${NOTIFICATION_SERVICE_URL:http://localhost:8088} + # OpenAPI access URL # http://localhost:8084/swagger-ui/index.html \ No newline at end of file