From 8edfd046c0e8c0fbc752e171759c8217f851a92d Mon Sep 17 00:00:00 2001 From: Shaik Mohammed Ashraf Ali Date: Thu, 30 Jul 2026 16:38:46 +0530 Subject: [PATCH 1/2] Add Phase 4 features --- .../src/main/resources/application.yml | 13 +- backend/course-service/.gitignore | 13 + backend/course-service/Dockerfile | 18 ++ backend/course-service/pom.xml | 97 +++++++ .../CourseServiceApplication.java | 20 ++ .../courseservice/config/JwtConfig.java | 19 ++ .../courseservice/config/SecurityConfig.java | 54 ++++ .../controller/CourseController.java | 27 +- .../example/courseservice/dto/CourseDTO.java | 70 +++++ .../example/courseservice}/entity/Course.java | 25 +- .../courseservice/entity/CourseStatus.java | 5 + .../exception/DuplicateResourceException.java | 7 + .../exception/ErrorResponse.java | 24 ++ .../exception/GlobalExceptionHandler.java | 60 +++++ .../exception/ResourceNotFoundException.java | 7 + .../repository/CourseRepository.java | 19 ++ .../security/JwtAuthenticationFilter.java | 49 ++++ .../courseservice}/service/CourseService.java | 35 ++- .../src/main/resources/application.properties | 26 ++ backend/docker-compose.yml | 250 ++++++++++++++++++ .../client/CourseClient.java | 48 ++++ .../client/StudentClient.java | 6 +- .../enrollmentservice/config/JwtConfig.java | 25 ++ .../config/SecurityConfig.java | 25 +- .../config/WebClientConfig.java | 10 + .../enrollmentservice/dto/CourseDTO.java | 47 +--- .../dto/EnrollmentResponseDTO.java | 15 +- .../enrollmentservice/entity/Enrollment.java | 20 +- .../exception/GlobalExceptionHandler.java | 4 +- .../StudentServiceUnavailableException.java | 9 +- .../UpstreamServiceUnavailableException.java | 13 + .../repository/CourseRepository.java | 13 - .../security/JwtAuthenticationFilter.java | 2 +- .../service/EnrollmentService.java | 84 +++--- .../src/main/resources/application.properties | 3 + backend/grade-service/.gitignore | 13 + backend/grade-service/Dockerfile | 18 ++ backend/grade-service/pom.xml | 102 +++++++ .../gradeservice/GradeServiceApplication.java | 20 ++ .../gradeservice/client/CourseClient.java | 42 +++ .../gradeservice/client/EnrollmentClient.java | 47 ++++ .../gradeservice/config/JwtConfig.java | 19 ++ .../gradeservice/config/SecurityConfig.java | 54 ++++ .../gradeservice/config/WebClientConfig.java | 30 +++ .../controller/GradeController.java | 53 ++++ .../example/gradeservice/dto/CourseDTO.java | 18 ++ .../gradeservice/dto/EnrollmentDTO.java | 22 ++ .../gradeservice/dto/GpaResponseDTO.java | 23 ++ .../dto/GradeAssignRequestDTO.java | 30 +++ .../gradeservice/dto/GradeResponseDTO.java | 43 +++ .../example/gradeservice/entity/Grade.java | 70 +++++ .../gradeservice/exception/ErrorResponse.java | 24 ++ .../exception/GlobalExceptionHandler.java | 65 +++++ .../exception/InvalidEnrollmentException.java | 8 + .../exception/ResourceNotFoundException.java | 7 + .../UpstreamServiceUnavailableException.java | 8 + .../repository/GradeRepository.java | 22 ++ .../security/JwtAuthenticationFilter.java | 49 ++++ .../gradeservice/service/GradeService.java | 144 ++++++++++ .../src/main/resources/application.properties | 31 +++ .../studentservice/config/JwtConfig.java | 27 ++ .../studentservice/config/SecurityConfig.java | 27 +- .../security/JwtAuthenticationFilter.java | 2 +- 63 files changed, 1994 insertions(+), 186 deletions(-) create mode 100644 backend/course-service/.gitignore create mode 100644 backend/course-service/Dockerfile create mode 100644 backend/course-service/pom.xml create mode 100644 backend/course-service/src/main/java/com/example/courseservice/CourseServiceApplication.java create mode 100644 backend/course-service/src/main/java/com/example/courseservice/config/JwtConfig.java create mode 100644 backend/course-service/src/main/java/com/example/courseservice/config/SecurityConfig.java rename backend/{enrollment-service/src/main/java/com/example/enrollmentservice => course-service/src/main/java/com/example/courseservice}/controller/CourseController.java (57%) create mode 100644 backend/course-service/src/main/java/com/example/courseservice/dto/CourseDTO.java rename backend/{enrollment-service/src/main/java/com/example/enrollmentservice => course-service/src/main/java/com/example/courseservice}/entity/Course.java (61%) create mode 100644 backend/course-service/src/main/java/com/example/courseservice/entity/CourseStatus.java create mode 100644 backend/course-service/src/main/java/com/example/courseservice/exception/DuplicateResourceException.java create mode 100644 backend/course-service/src/main/java/com/example/courseservice/exception/ErrorResponse.java create mode 100644 backend/course-service/src/main/java/com/example/courseservice/exception/GlobalExceptionHandler.java create mode 100644 backend/course-service/src/main/java/com/example/courseservice/exception/ResourceNotFoundException.java create mode 100644 backend/course-service/src/main/java/com/example/courseservice/repository/CourseRepository.java create mode 100644 backend/course-service/src/main/java/com/example/courseservice/security/JwtAuthenticationFilter.java rename backend/{enrollment-service/src/main/java/com/example/enrollmentservice => course-service/src/main/java/com/example/courseservice}/service/CourseService.java (60%) create mode 100644 backend/course-service/src/main/resources/application.properties create mode 100644 backend/docker-compose.yml create mode 100644 backend/enrollment-service/src/main/java/com/example/enrollmentservice/client/CourseClient.java create mode 100644 backend/enrollment-service/src/main/java/com/example/enrollmentservice/config/JwtConfig.java create mode 100644 backend/enrollment-service/src/main/java/com/example/enrollmentservice/exception/UpstreamServiceUnavailableException.java delete mode 100644 backend/enrollment-service/src/main/java/com/example/enrollmentservice/repository/CourseRepository.java create mode 100644 backend/grade-service/.gitignore create mode 100644 backend/grade-service/Dockerfile create mode 100644 backend/grade-service/pom.xml create mode 100644 backend/grade-service/src/main/java/com/example/gradeservice/GradeServiceApplication.java create mode 100644 backend/grade-service/src/main/java/com/example/gradeservice/client/CourseClient.java create mode 100644 backend/grade-service/src/main/java/com/example/gradeservice/client/EnrollmentClient.java create mode 100644 backend/grade-service/src/main/java/com/example/gradeservice/config/JwtConfig.java create mode 100644 backend/grade-service/src/main/java/com/example/gradeservice/config/SecurityConfig.java create mode 100644 backend/grade-service/src/main/java/com/example/gradeservice/config/WebClientConfig.java create mode 100644 backend/grade-service/src/main/java/com/example/gradeservice/controller/GradeController.java create mode 100644 backend/grade-service/src/main/java/com/example/gradeservice/dto/CourseDTO.java create mode 100644 backend/grade-service/src/main/java/com/example/gradeservice/dto/EnrollmentDTO.java create mode 100644 backend/grade-service/src/main/java/com/example/gradeservice/dto/GpaResponseDTO.java create mode 100644 backend/grade-service/src/main/java/com/example/gradeservice/dto/GradeAssignRequestDTO.java create mode 100644 backend/grade-service/src/main/java/com/example/gradeservice/dto/GradeResponseDTO.java create mode 100644 backend/grade-service/src/main/java/com/example/gradeservice/entity/Grade.java create mode 100644 backend/grade-service/src/main/java/com/example/gradeservice/exception/ErrorResponse.java create mode 100644 backend/grade-service/src/main/java/com/example/gradeservice/exception/GlobalExceptionHandler.java create mode 100644 backend/grade-service/src/main/java/com/example/gradeservice/exception/InvalidEnrollmentException.java create mode 100644 backend/grade-service/src/main/java/com/example/gradeservice/exception/ResourceNotFoundException.java create mode 100644 backend/grade-service/src/main/java/com/example/gradeservice/exception/UpstreamServiceUnavailableException.java create mode 100644 backend/grade-service/src/main/java/com/example/gradeservice/repository/GradeRepository.java create mode 100644 backend/grade-service/src/main/java/com/example/gradeservice/security/JwtAuthenticationFilter.java create mode 100644 backend/grade-service/src/main/java/com/example/gradeservice/service/GradeService.java create mode 100644 backend/grade-service/src/main/resources/application.properties create mode 100644 backend/student-service/src/main/java/com/example/studentservice/config/JwtConfig.java diff --git a/backend/api-gateway/src/main/resources/application.yml b/backend/api-gateway/src/main/resources/application.yml index fd19482..7ffdbb7 100644 --- a/backend/api-gateway/src/main/resources/application.yml +++ b/backend/api-gateway/src/main/resources/application.yml @@ -17,15 +17,20 @@ spring: predicates: - Path=/api/v1/students/** - - id: enrollment-service-courses + - id: enrollment-service uri: ${ENROLLMENT_SERVICE_URL:http://localhost:8082} + predicates: + - Path=/api/v1/enrollments/** + + - id: course-service + uri: ${COURSE_SERVICE_URL:http://localhost:8083} predicates: - Path=/api/v1/courses/** - - id: enrollment-service-enrollments - uri: ${ENROLLMENT_SERVICE_URL:http://localhost:8082} + - id: grade-service + uri: ${GRADE_SERVICE_URL:http://localhost:8084} predicates: - - Path=/api/v1/enrollments/** + - Path=/api/v1/grades/** globalcors: cors-configurations: diff --git a/backend/course-service/.gitignore b/backend/course-service/.gitignore new file mode 100644 index 0000000..6255ee4 --- /dev/null +++ b/backend/course-service/.gitignore @@ -0,0 +1,13 @@ +target/ +*.class +*.jar +!.mvn/wrapper/maven-wrapper.jar +.idea/ +*.iml +.vscode/ +.DS_Store +*.log +HELP.md +.mvn/ +mvnw +mvnw.cmd diff --git a/backend/course-service/Dockerfile b/backend/course-service/Dockerfile new file mode 100644 index 0000000..a7039ae --- /dev/null +++ b/backend/course-service/Dockerfile @@ -0,0 +1,18 @@ +# ---- Build stage ---- +FROM maven:3.9-eclipse-temurin-17 AS build +WORKDIR /app + +COPY common-lib ./common-lib +RUN cd common-lib && mvn -B clean install -DskipTests + +COPY course-service/pom.xml ./course-service/pom.xml +RUN cd course-service && mvn -B dependency:go-offline +COPY course-service/src ./course-service/src +RUN cd course-service && mvn -B clean package -DskipTests + +# ---- Run stage ---- +FROM eclipse-temurin:17-jre-alpine +WORKDIR /app +COPY --from=build /app/course-service/target/*.jar app.jar +EXPOSE 8083 +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/backend/course-service/pom.xml b/backend/course-service/pom.xml new file mode 100644 index 0000000..57f5d1d --- /dev/null +++ b/backend/course-service/pom.xml @@ -0,0 +1,97 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.3.4 + + + + com.example + course-service + 1.0.0 + course-service + Course catalog microservice - capacity, credits, semester, instructor, department, status + + + 17 + + + + + com.example + common-lib + 1.0.0 + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-actuator + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.6.0 + + + + com.mysql + mysql-connector-j + runtime + + + org.projectlombok + lombok + true + + + org.springframework.boot + spring-boot-starter-test + test + + + com.h2database + h2 + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + + diff --git a/backend/course-service/src/main/java/com/example/courseservice/CourseServiceApplication.java b/backend/course-service/src/main/java/com/example/courseservice/CourseServiceApplication.java new file mode 100644 index 0000000..1ace02f --- /dev/null +++ b/backend/course-service/src/main/java/com/example/courseservice/CourseServiceApplication.java @@ -0,0 +1,20 @@ +package com.example.courseservice; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * Entry point for the Course Service. Owns the course catalog: capacity, + * credits, semester, instructor, department, and status. Previously this + * lived inside enrollment-service; it's split out here so course data has + * its own bounded context and database (course_db), with + * enrollment-service calling it over REST via CourseClient instead of + * owning course rows directly. + */ +@SpringBootApplication +public class CourseServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(CourseServiceApplication.class, args); + } +} diff --git a/backend/course-service/src/main/java/com/example/courseservice/config/JwtConfig.java b/backend/course-service/src/main/java/com/example/courseservice/config/JwtConfig.java new file mode 100644 index 0000000..36a8483 --- /dev/null +++ b/backend/course-service/src/main/java/com/example/courseservice/config/JwtConfig.java @@ -0,0 +1,19 @@ +package com.example.courseservice.config; + +import com.example.common.security.JwtTokenValidator; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** Kept separate from SecurityConfig to avoid a circular bean dependency - see student-service's JwtConfig for the full explanation. */ +@Configuration +public class JwtConfig { + + @Value("${jwt.secret}") + private String jwtSecret; + + @Bean + public JwtTokenValidator jwtTokenValidator() { + return new JwtTokenValidator(jwtSecret); + } +} diff --git a/backend/course-service/src/main/java/com/example/courseservice/config/SecurityConfig.java b/backend/course-service/src/main/java/com/example/courseservice/config/SecurityConfig.java new file mode 100644 index 0000000..f8ecfb5 --- /dev/null +++ b/backend/course-service/src/main/java/com/example/courseservice/config/SecurityConfig.java @@ -0,0 +1,54 @@ +package com.example.courseservice.config; + +import com.example.courseservice.security.JwtAuthenticationFilter; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.List; + +@Configuration +@EnableWebSecurity +@EnableMethodSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .csrf(csrf -> csrf.disable()) + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/actuator/health", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll() + .anyRequest().authenticated() + ) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOriginPatterns(List.of("*")); + configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); + configuration.setAllowedHeaders(List.of("*")); + configuration.setAllowCredentials(true); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } +} diff --git a/backend/enrollment-service/src/main/java/com/example/enrollmentservice/controller/CourseController.java b/backend/course-service/src/main/java/com/example/courseservice/controller/CourseController.java similarity index 57% rename from backend/enrollment-service/src/main/java/com/example/enrollmentservice/controller/CourseController.java rename to backend/course-service/src/main/java/com/example/courseservice/controller/CourseController.java index 996e1f0..5b26760 100644 --- a/backend/enrollment-service/src/main/java/com/example/enrollmentservice/controller/CourseController.java +++ b/backend/course-service/src/main/java/com/example/courseservice/controller/CourseController.java @@ -1,7 +1,9 @@ -package com.example.enrollmentservice.controller; +package com.example.courseservice.controller; -import com.example.enrollmentservice.dto.CourseDTO; -import com.example.enrollmentservice.service.CourseService; +import com.example.courseservice.dto.CourseDTO; +import com.example.courseservice.service.CourseService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; @@ -12,43 +14,56 @@ import java.util.List; /** - * Access rules: any authenticated user (ADMIN or STUDENT) can browse - * courses - students need this to see what's available to enroll in. - * Only ADMIN can create, update, or delete a course. + * Any authenticated user (ADMIN or STUDENT) can browse courses - students + * need this to see what's available to enroll in. Only ADMIN can create, + * update, or delete a course. */ @RestController @RequestMapping("/api/v1/courses") @RequiredArgsConstructor +@Tag(name = "Courses", description = "Course catalog: capacity, credits, semester, instructor, department, status") public class CourseController { private final CourseService courseService; @PostMapping @PreAuthorize("hasRole('ADMIN')") + @Operation(summary = "Create a course (ADMIN only)") public ResponseEntity createCourse(@Valid @RequestBody CourseDTO courseDTO) { return new ResponseEntity<>(courseService.createCourse(courseDTO), HttpStatus.CREATED); } @GetMapping("/{id}") + @Operation(summary = "Get a course by id") public ResponseEntity getCourseById(@PathVariable Long id) { return ResponseEntity.ok(courseService.getCourseById(id)); } @GetMapping + @Operation(summary = "List all courses") public ResponseEntity> getAllCourses() { return ResponseEntity.ok(courseService.getAllCourses()); } @PutMapping("/{id}") @PreAuthorize("hasRole('ADMIN')") + @Operation(summary = "Update a course (ADMIN only)") public ResponseEntity updateCourse(@PathVariable Long id, @RequestBody CourseDTO courseDTO) { return ResponseEntity.ok(courseService.updateCourse(id, courseDTO)); } @DeleteMapping("/{id}") @PreAuthorize("hasRole('ADMIN')") + @Operation(summary = "Delete a course (ADMIN only)") public ResponseEntity deleteCourse(@PathVariable Long id) { courseService.deleteCourse(id); return ResponseEntity.noContent().build(); } + + /** Internal check used by enrollment-service before creating an enrollment. */ + @GetMapping("/{id}/exists") + @Operation(summary = "Lightweight existence check (used by enrollment-service)") + public ResponseEntity existsById(@PathVariable Long id) { + return ResponseEntity.ok(courseService.existsById(id)); + } } diff --git a/backend/course-service/src/main/java/com/example/courseservice/dto/CourseDTO.java b/backend/course-service/src/main/java/com/example/courseservice/dto/CourseDTO.java new file mode 100644 index 0000000..a129d33 --- /dev/null +++ b/backend/course-service/src/main/java/com/example/courseservice/dto/CourseDTO.java @@ -0,0 +1,70 @@ +package com.example.courseservice.dto; + +import com.example.courseservice.entity.Course; +import com.example.courseservice.entity.CourseStatus; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CourseDTO { + + private Long id; + + @NotBlank(message = "Course code is required") + private String courseCode; + + @NotBlank(message = "Title is required") + private String title; + + private String description; + + @NotNull(message = "Credits is required") + @Min(value = 1, message = "Credits must be at least 1") + private Integer credits; + + @Min(value = 1, message = "Capacity must be at least 1") + private Integer capacity; + + private String semester; + private String instructor; + private String department; + private String status; + + public static CourseDTO fromEntity(Course course) { + return CourseDTO.builder() + .id(course.getId()) + .courseCode(course.getCourseCode()) + .title(course.getTitle()) + .description(course.getDescription()) + .credits(course.getCredits()) + .capacity(course.getCapacity()) + .semester(course.getSemester()) + .instructor(course.getInstructor()) + .department(course.getDepartment()) + .status(course.getStatus() != null ? course.getStatus().name() : null) + .build(); + } + + public Course toEntity() { + return Course.builder() + .id(this.id) + .courseCode(this.courseCode) + .title(this.title) + .description(this.description) + .credits(this.credits) + .capacity(this.capacity != null ? this.capacity : 30) + .semester(this.semester) + .instructor(this.instructor) + .department(this.department) + .status(this.status != null ? CourseStatus.valueOf(this.status.toUpperCase()) : CourseStatus.ACTIVE) + .build(); + } +} diff --git a/backend/enrollment-service/src/main/java/com/example/enrollmentservice/entity/Course.java b/backend/course-service/src/main/java/com/example/courseservice/entity/Course.java similarity index 61% rename from backend/enrollment-service/src/main/java/com/example/enrollmentservice/entity/Course.java rename to backend/course-service/src/main/java/com/example/courseservice/entity/Course.java index 3d2cae0..8cdaaeb 100644 --- a/backend/enrollment-service/src/main/java/com/example/enrollmentservice/entity/Course.java +++ b/backend/course-service/src/main/java/com/example/courseservice/entity/Course.java @@ -1,4 +1,4 @@ -package com.example.enrollmentservice.entity; +package com.example.courseservice.entity; import jakarta.persistence.*; import lombok.AllArgsConstructor; @@ -10,7 +10,9 @@ @Entity @Table(name = "courses", indexes = { - @Index(name = "idx_course_code", columnList = "courseCode", unique = true) + @Index(name = "idx_course_code", columnList = "courseCode", unique = true), + @Index(name = "idx_course_semester", columnList = "semester"), + @Index(name = "idx_course_department", columnList = "department") }) @Data @Builder @@ -28,7 +30,7 @@ public class Course { @Column(nullable = false, length = 150) private String title; - @Column(length = 1000) + @Column(length = 2000) private String description; @Column(nullable = false) @@ -38,7 +40,22 @@ public class Course { @Builder.Default private Integer capacity = 30; - @Column(nullable = false) + /** e.g. "FALL2026", "SPRING2027" - kept as a free-form string for flexibility. */ + @Column(length = 20) + private String semester; + + @Column(length = 100) + private String instructor; + + @Column(length = 100) + private String department; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + @Builder.Default + private CourseStatus status = CourseStatus.ACTIVE; + + @Column(nullable = false, updatable = false) private LocalDateTime createdAt; private LocalDateTime updatedAt; diff --git a/backend/course-service/src/main/java/com/example/courseservice/entity/CourseStatus.java b/backend/course-service/src/main/java/com/example/courseservice/entity/CourseStatus.java new file mode 100644 index 0000000..cabda77 --- /dev/null +++ b/backend/course-service/src/main/java/com/example/courseservice/entity/CourseStatus.java @@ -0,0 +1,5 @@ +package com.example.courseservice.entity; + +public enum CourseStatus { + ACTIVE, INACTIVE, COMPLETED, CANCELLED +} diff --git a/backend/course-service/src/main/java/com/example/courseservice/exception/DuplicateResourceException.java b/backend/course-service/src/main/java/com/example/courseservice/exception/DuplicateResourceException.java new file mode 100644 index 0000000..da8899c --- /dev/null +++ b/backend/course-service/src/main/java/com/example/courseservice/exception/DuplicateResourceException.java @@ -0,0 +1,7 @@ +package com.example.courseservice.exception; + +public class DuplicateResourceException extends RuntimeException { + public DuplicateResourceException(String message) { + super(message); + } +} diff --git a/backend/course-service/src/main/java/com/example/courseservice/exception/ErrorResponse.java b/backend/course-service/src/main/java/com/example/courseservice/exception/ErrorResponse.java new file mode 100644 index 0000000..e72c306 --- /dev/null +++ b/backend/course-service/src/main/java/com/example/courseservice/exception/ErrorResponse.java @@ -0,0 +1,24 @@ +package com.example.courseservice.exception; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.Map; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ErrorResponse { + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") + private LocalDateTime timestamp; + private int status; + private String error; + private String message; + private String path; + private Map validationErrors; +} diff --git a/backend/course-service/src/main/java/com/example/courseservice/exception/GlobalExceptionHandler.java b/backend/course-service/src/main/java/com/example/courseservice/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..06e5684 --- /dev/null +++ b/backend/course-service/src/main/java/com/example/courseservice/exception/GlobalExceptionHandler.java @@ -0,0 +1,60 @@ +package com.example.courseservice.exception; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(AccessDeniedException.class) + public ResponseEntity handleAccessDenied(AccessDeniedException ex, HttpServletRequest req) { + return build(HttpStatus.FORBIDDEN, "Forbidden", "You do not have permission to access this resource", req, null); + } + + @ExceptionHandler(ResourceNotFoundException.class) + public ResponseEntity handleNotFound(ResourceNotFoundException ex, HttpServletRequest req) { + return build(HttpStatus.NOT_FOUND, "Not Found", ex.getMessage(), req, null); + } + + @ExceptionHandler(DuplicateResourceException.class) + public ResponseEntity handleDuplicate(DuplicateResourceException ex, HttpServletRequest req) { + return build(HttpStatus.CONFLICT, "Conflict", ex.getMessage(), req, null); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidation(MethodArgumentNotValidException ex, HttpServletRequest req) { + Map validationErrors = new HashMap<>(); + for (FieldError fieldError : ex.getBindingResult().getFieldErrors()) { + validationErrors.put(fieldError.getField(), fieldError.getDefaultMessage()); + } + return build(HttpStatus.BAD_REQUEST, "Validation Failed", "One or more fields are invalid", req, validationErrors); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleGeneric(Exception ex, HttpServletRequest req) { + return build(HttpStatus.INTERNAL_SERVER_ERROR, "Internal Server Error", ex.getMessage(), req, null); + } + + private ResponseEntity build(HttpStatus status, String error, String message, + HttpServletRequest req, Map validationErrors) { + ErrorResponse body = ErrorResponse.builder() + .timestamp(LocalDateTime.now()) + .status(status.value()) + .error(error) + .message(message) + .path(req.getRequestURI()) + .validationErrors(validationErrors) + .build(); + return new ResponseEntity<>(body, status); + } +} diff --git a/backend/course-service/src/main/java/com/example/courseservice/exception/ResourceNotFoundException.java b/backend/course-service/src/main/java/com/example/courseservice/exception/ResourceNotFoundException.java new file mode 100644 index 0000000..78004e2 --- /dev/null +++ b/backend/course-service/src/main/java/com/example/courseservice/exception/ResourceNotFoundException.java @@ -0,0 +1,7 @@ +package com.example.courseservice.exception; + +public class ResourceNotFoundException extends RuntimeException { + public ResourceNotFoundException(String message) { + super(message); + } +} diff --git a/backend/course-service/src/main/java/com/example/courseservice/repository/CourseRepository.java b/backend/course-service/src/main/java/com/example/courseservice/repository/CourseRepository.java new file mode 100644 index 0000000..fec81ef --- /dev/null +++ b/backend/course-service/src/main/java/com/example/courseservice/repository/CourseRepository.java @@ -0,0 +1,19 @@ +package com.example.courseservice.repository; + +import com.example.courseservice.entity.Course; +import com.example.courseservice.entity.CourseStatus; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface CourseRepository extends JpaRepository { + Optional findByCourseCode(String courseCode); + boolean existsByCourseCode(String courseCode); + Page findBySemester(String semester, Pageable pageable); + Page findByDepartmentIgnoreCase(String department, Pageable pageable); + Page findByStatus(CourseStatus status, Pageable pageable); +} diff --git a/backend/course-service/src/main/java/com/example/courseservice/security/JwtAuthenticationFilter.java b/backend/course-service/src/main/java/com/example/courseservice/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..8e6c8fe --- /dev/null +++ b/backend/course-service/src/main/java/com/example/courseservice/security/JwtAuthenticationFilter.java @@ -0,0 +1,49 @@ +package com.example.courseservice.security; + +import com.example.common.security.JwtPrincipal; +import com.example.common.security.JwtTokenValidator; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.lang.NonNull; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtTokenValidator jwtTokenValidator; + + @Override + protected void doFilterInternal(@NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain) throws ServletException, IOException { + + String authHeader = request.getHeader("Authorization"); + + if (authHeader != null && authHeader.startsWith("Bearer ")) { + String token = authHeader.substring(7); + + if (jwtTokenValidator.isValid(token) && SecurityContextHolder.getContext().getAuthentication() == null) { + JwtPrincipal principal = jwtTokenValidator.extractPrincipal(token); + List authorities = List.of(new SimpleGrantedAuthority("ROLE_" + principal.getRole())); + + UsernamePasswordAuthenticationToken authToken = + new UsernamePasswordAuthenticationToken(principal, null, authorities); + SecurityContextHolder.getContext().setAuthentication(authToken); + } + } + + filterChain.doFilter(request, response); + } +} diff --git a/backend/enrollment-service/src/main/java/com/example/enrollmentservice/service/CourseService.java b/backend/course-service/src/main/java/com/example/courseservice/service/CourseService.java similarity index 60% rename from backend/enrollment-service/src/main/java/com/example/enrollmentservice/service/CourseService.java rename to backend/course-service/src/main/java/com/example/courseservice/service/CourseService.java index 99da53c..aa5c700 100644 --- a/backend/enrollment-service/src/main/java/com/example/enrollmentservice/service/CourseService.java +++ b/backend/course-service/src/main/java/com/example/courseservice/service/CourseService.java @@ -1,10 +1,10 @@ -package com.example.enrollmentservice.service; +package com.example.courseservice.service; -import com.example.enrollmentservice.dto.CourseDTO; -import com.example.enrollmentservice.entity.Course; -import com.example.enrollmentservice.exception.DuplicateResourceException; -import com.example.enrollmentservice.exception.ResourceNotFoundException; -import com.example.enrollmentservice.repository.CourseRepository; +import com.example.courseservice.dto.CourseDTO; +import com.example.courseservice.entity.Course; +import com.example.courseservice.exception.DuplicateResourceException; +import com.example.courseservice.exception.ResourceNotFoundException; +import com.example.courseservice.repository.CourseRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -29,9 +29,7 @@ public CourseDTO createCourse(CourseDTO dto) { @Transactional(readOnly = true) public CourseDTO getCourseById(Long id) { - Course course = courseRepository.findById(id) - .orElseThrow(() -> new ResourceNotFoundException("Course not found with id: " + id)); - return CourseDTO.fromEntity(course); + return CourseDTO.fromEntity(findOrThrow(id)); } @Transactional(readOnly = true) @@ -40,14 +38,22 @@ public List getAllCourses() { } public CourseDTO updateCourse(Long id, CourseDTO dto) { - Course existing = courseRepository.findById(id) - .orElseThrow(() -> new ResourceNotFoundException("Course not found with id: " + id)); + Course existing = findOrThrow(id); + + if (dto.getCourseCode() != null && !dto.getCourseCode().equalsIgnoreCase(existing.getCourseCode()) + && courseRepository.existsByCourseCode(dto.getCourseCode())) { + throw new DuplicateResourceException("Course with code '" + dto.getCourseCode() + "' already exists"); + } if (dto.getCourseCode() != null) existing.setCourseCode(dto.getCourseCode()); if (dto.getTitle() != null) existing.setTitle(dto.getTitle()); if (dto.getDescription() != null) existing.setDescription(dto.getDescription()); if (dto.getCredits() != null) existing.setCredits(dto.getCredits()); if (dto.getCapacity() != null) existing.setCapacity(dto.getCapacity()); + if (dto.getSemester() != null) existing.setSemester(dto.getSemester()); + if (dto.getInstructor() != null) existing.setInstructor(dto.getInstructor()); + if (dto.getDepartment() != null) existing.setDepartment(dto.getDepartment()); + if (dto.getStatus() != null) existing.setStatus(com.example.courseservice.entity.CourseStatus.valueOf(dto.getStatus().toUpperCase())); return CourseDTO.fromEntity(courseRepository.save(existing)); } @@ -59,9 +65,12 @@ public void deleteCourse(Long id) { courseRepository.deleteById(id); } - /** Package-private helper for EnrollmentService to fetch the raw entity. */ @Transactional(readOnly = true) - Course getCourseEntity(Long id) { + public boolean existsById(Long id) { + return courseRepository.existsById(id); + } + + private Course findOrThrow(Long id) { return courseRepository.findById(id) .orElseThrow(() -> new ResourceNotFoundException("Course not found with id: " + id)); } diff --git a/backend/course-service/src/main/resources/application.properties b/backend/course-service/src/main/resources/application.properties new file mode 100644 index 0000000..ba96cac --- /dev/null +++ b/backend/course-service/src/main/resources/application.properties @@ -0,0 +1,26 @@ +spring.application.name=course-service +server.port=8083 + +# ---- MySQL Datasource ---- +spring.datasource.url=jdbc:mysql://${DB_HOST:localhost}:${DB_PORT:3306}/${DB_NAME:course_db}?createDatabaseIfNotExist=true&useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC +spring.datasource.username=${DB_USER:root} +spring.datasource.password=${DB_PASSWORD:root} +spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver + +# ---- JPA / Hibernate ---- +spring.jpa.hibernate.ddl-auto=update +spring.jpa.show-sql=true +spring.jpa.properties.hibernate.format_sql=true + +# ---- JWT (must match auth-service's jwt.secret) ---- +jwt.secret=${JWT_SECRET:MzM0NmM4NDNhOWM5NDcyM2FhZmY3NmY2ZGRhYTQ5ZjM0Njc4OTBhYmNkZWYxMjM0NTY3ODkwYWJjZGVm} + +# ---- Swagger ---- +springdoc.api-docs.path=/v3/api-docs +springdoc.swagger-ui.path=/swagger-ui.html + +# ---- Actuator ---- +management.endpoints.web.exposure.include=health,info,metrics + +# ---- Logging ---- +logging.level.com.example.courseservice=DEBUG diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml new file mode 100644 index 0000000..bf58e68 --- /dev/null +++ b/backend/docker-compose.yml @@ -0,0 +1,250 @@ +version: "3.8" + +services: + mysql-auth: + image: mysql:8.0 + container_name: mysql-auth + restart: unless-stopped + environment: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: auth_db + ports: + - "3310:3306" + volumes: + - mysql_auth_data:/var/lib/mysql + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-proot"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - sms-net + + mysql-student: + image: mysql:8.0 + container_name: mysql-student + restart: unless-stopped + environment: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: student_db + ports: + - "3307:3306" + volumes: + - mysql_student_data:/var/lib/mysql + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-proot"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - sms-net + + mysql-enrollment: + image: mysql:8.0 + container_name: mysql-enrollment + restart: unless-stopped + environment: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: enrollment_db + ports: + - "3308:3306" + volumes: + - mysql_enrollment_data:/var/lib/mysql + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-proot"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - sms-net + + mysql-course: + image: mysql:8.0 + container_name: mysql-course + restart: unless-stopped + environment: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: course_db + ports: + - "3311:3306" + volumes: + - mysql_course_data:/var/lib/mysql + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-proot"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - sms-net + + mysql-grade: + image: mysql:8.0 + container_name: mysql-grade + restart: unless-stopped + environment: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: grade_db + ports: + - "3312:3306" + volumes: + - mysql_grade_data:/var/lib/mysql + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-proot"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - sms-net + + auth-service: + build: + context: . + dockerfile: auth-service/Dockerfile + container_name: auth-service + restart: unless-stopped + depends_on: + mysql-auth: + condition: service_healthy + environment: + DB_HOST: mysql-auth + DB_PORT: 3306 + DB_NAME: auth_db + DB_USER: root + DB_PASSWORD: root + JWT_SECRET: ${JWT_SECRET:-MzM0NmM4NDNhOWM5NDcyM2FhZmY3NmY2ZGRhYTQ5ZjM0Njc4OTBhYmNkZWYxMjM0NTY3ODkwYWJjZGVm} + ports: + - "8080:8080" + networks: + - sms-net + + student-service: + build: + context: . + dockerfile: student-service/Dockerfile + container_name: student-service + restart: unless-stopped + depends_on: + mysql-student: + condition: service_healthy + environment: + DB_HOST: mysql-student + DB_PORT: 3306 + DB_NAME: student_db + DB_USER: root + DB_PASSWORD: root + JWT_SECRET: ${JWT_SECRET:-MzM0NmM4NDNhOWM5NDcyM2FhZmY3NmY2ZGRhYTQ5ZjM0Njc4OTBhYmNkZWYxMjM0NTY3ODkwYWJjZGVm} + ports: + - "8081:8081" + networks: + - sms-net + + course-service: + build: + context: . + dockerfile: course-service/Dockerfile + container_name: course-service + restart: unless-stopped + depends_on: + mysql-course: + condition: service_healthy + environment: + DB_HOST: mysql-course + DB_PORT: 3306 + DB_NAME: course_db + DB_USER: root + DB_PASSWORD: root + JWT_SECRET: ${JWT_SECRET:-MzM0NmM4NDNhOWM5NDcyM2FhZmY3NmY2ZGRhYTQ5ZjM0Njc4OTBhYmNkZWYxMjM0NTY3ODkwYWJjZGVm} + ports: + - "8083:8083" + networks: + - sms-net + + enrollment-service: + build: + context: . + dockerfile: enrollment-service/Dockerfile + container_name: enrollment-service + restart: unless-stopped + depends_on: + mysql-enrollment: + condition: service_healthy + student-service: + condition: service_started + course-service: + condition: service_started + environment: + DB_HOST: mysql-enrollment + DB_PORT: 3306 + DB_NAME: enrollment_db + DB_USER: root + DB_PASSWORD: root + JWT_SECRET: ${JWT_SECRET:-MzM0NmM4NDNhOWM5NDcyM2FhZmY3NmY2ZGRhYTQ5ZjM0Njc4OTBhYmNkZWYxMjM0NTY3ODkwYWJjZGVm} + STUDENT_SERVICE_URL: http://student-service:8081 + COURSE_SERVICE_URL: http://course-service:8083 + ports: + - "8082:8082" + networks: + - sms-net + + grade-service: + build: + context: . + dockerfile: grade-service/Dockerfile + container_name: grade-service + restart: unless-stopped + depends_on: + mysql-grade: + condition: service_healthy + enrollment-service: + condition: service_started + course-service: + condition: service_started + environment: + DB_HOST: mysql-grade + DB_PORT: 3306 + DB_NAME: grade_db + DB_USER: root + DB_PASSWORD: root + JWT_SECRET: ${JWT_SECRET:-MzM0NmM4NDNhOWM5NDcyM2FhZmY3NmY2ZGRhYTQ5ZjM0Njc4OTBhYmNkZWYxMjM0NTY3ODkwYWJjZGVm} + COURSE_SERVICE_URL: http://course-service:8083 + STUDENT_SERVICE_URL: http://student-service:8081 + ENROLLMENT_SERVICE_URL: http://enrollment-service:8082 + ports: + - "8084:8084" + networks: + - sms-net + + api-gateway: + build: + context: . + dockerfile: api-gateway/Dockerfile + container_name: api-gateway + restart: unless-stopped + depends_on: + - auth-service + - student-service + - enrollment-service + - course-service + - grade-service + environment: + JWT_SECRET: ${JWT_SECRET:-MzM0NmM4NDNhOWM5NDcyM2FhZmY3NmY2ZGRhYTQ5ZjM0Njc4OTBhYmNkZWYxMjM0NTY3ODkwYWJjZGVm} + AUTH_SERVICE_URL: http://auth-service:8080 + STUDENT_SERVICE_URL: http://student-service:8081 + ENROLLMENT_SERVICE_URL: http://enrollment-service:8082 + COURSE_SERVICE_URL: http://course-service:8083 + GRADE_SERVICE_URL: http://grade-service:8084 + ports: + - "9000:9000" + networks: + - sms-net + +networks: + sms-net: + driver: bridge + +volumes: + mysql_auth_data: + mysql_student_data: + mysql_enrollment_data: + mysql_course_data: + mysql_grade_data: diff --git a/backend/enrollment-service/src/main/java/com/example/enrollmentservice/client/CourseClient.java b/backend/enrollment-service/src/main/java/com/example/enrollmentservice/client/CourseClient.java new file mode 100644 index 0000000..3d43365 --- /dev/null +++ b/backend/enrollment-service/src/main/java/com/example/enrollmentservice/client/CourseClient.java @@ -0,0 +1,48 @@ +package com.example.enrollmentservice.client; + +import com.example.enrollmentservice.dto.CourseDTO; +import com.example.enrollmentservice.exception.UpstreamServiceUnavailableException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientResponseException; + +import java.time.Duration; +import java.util.Optional; + +/** + * Encapsulates all HTTP calls from enrollment-service to course-service. + * Mirrors StudentClient's pattern exactly: forwards the caller's own + * bearer token downstream (pass-through auth) rather than using a + * service-account credential. + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class CourseClient { + + private final WebClient courseServiceWebClient; + + public Optional getCourseById(Long courseId, String bearerToken) { + try { + CourseDTO course = courseServiceWebClient.get() + .uri("/api/v1/courses/{id}", courseId) + .header(HttpHeaders.AUTHORIZATION, bearerToken) + .retrieve() + .bodyToMono(CourseDTO.class) + .timeout(Duration.ofSeconds(5)) + .block(); + return Optional.ofNullable(course); + } catch (WebClientResponseException.NotFound ex) { + return Optional.empty(); + } catch (WebClientResponseException.Forbidden | WebClientResponseException.Unauthorized ex) { + throw ex; + } catch (Exception ex) { + log.error("Failed to reach course-service for id {}: {}", courseId, ex.getMessage()); + throw new UpstreamServiceUnavailableException( + "Unable to verify course " + courseId + " - course-service is unreachable"); + } + } +} diff --git a/backend/enrollment-service/src/main/java/com/example/enrollmentservice/client/StudentClient.java b/backend/enrollment-service/src/main/java/com/example/enrollmentservice/client/StudentClient.java index dd74c8b..e55d862 100644 --- a/backend/enrollment-service/src/main/java/com/example/enrollmentservice/client/StudentClient.java +++ b/backend/enrollment-service/src/main/java/com/example/enrollmentservice/client/StudentClient.java @@ -1,7 +1,7 @@ package com.example.enrollmentservice.client; import com.example.enrollmentservice.dto.StudentDTO; -import com.example.enrollmentservice.exception.StudentServiceUnavailableException; +import com.example.enrollmentservice.exception.UpstreamServiceUnavailableException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpHeaders; @@ -50,7 +50,7 @@ public Optional getStudentById(Long studentId, String bearerToken) { throw ex; } catch (Exception ex) { log.error("Failed to reach student-service for id {}: {}", studentId, ex.getMessage()); - throw new StudentServiceUnavailableException( + throw new UpstreamServiceUnavailableException( "Unable to verify student " + studentId + " - student-service is unreachable"); } } @@ -71,7 +71,7 @@ public boolean studentExists(Long studentId, String bearerToken) { return Boolean.TRUE.equals(exists); } catch (Exception ex) { log.error("Failed to reach student-service to check existence of {}: {}", studentId, ex.getMessage()); - throw new StudentServiceUnavailableException( + throw new UpstreamServiceUnavailableException( "Unable to verify student " + studentId + " - student-service is unreachable"); } } diff --git a/backend/enrollment-service/src/main/java/com/example/enrollmentservice/config/JwtConfig.java b/backend/enrollment-service/src/main/java/com/example/enrollmentservice/config/JwtConfig.java new file mode 100644 index 0000000..22d60ee --- /dev/null +++ b/backend/enrollment-service/src/main/java/com/example/enrollmentservice/config/JwtConfig.java @@ -0,0 +1,25 @@ +package com.example.enrollmentservice.config; + +import com.example.common.security.JwtTokenValidator; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Kept separate from SecurityConfig on purpose - see the equivalent + * class in student-service for the full explanation. In short: + * SecurityConfig depends on JwtAuthenticationFilter, which depends on + * JwtTokenValidator; defining JwtTokenValidator as a @Bean inside + * SecurityConfig itself would create a circular dependency. + */ +@Configuration +public class JwtConfig { + + @Value("${jwt.secret}") + private String jwtSecret; + + @Bean + public JwtTokenValidator jwtTokenValidator() { + return new JwtTokenValidator(jwtSecret); + } +} diff --git a/backend/enrollment-service/src/main/java/com/example/enrollmentservice/config/SecurityConfig.java b/backend/enrollment-service/src/main/java/com/example/enrollmentservice/config/SecurityConfig.java index b75a6d9..9f0d2f9 100644 --- a/backend/enrollment-service/src/main/java/com/example/enrollmentservice/config/SecurityConfig.java +++ b/backend/enrollment-service/src/main/java/com/example/enrollmentservice/config/SecurityConfig.java @@ -1,9 +1,7 @@ package com.example.enrollmentservice.config; -import com.example.common.security.JwtTokenValidator; import com.example.enrollmentservice.security.JwtAuthenticationFilter; import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; @@ -24,26 +22,10 @@ @RequiredArgsConstructor public class SecurityConfig { - @Value("${jwt.secret}") - private String jwtSecret; + private final JwtAuthenticationFilter jwtAuthenticationFilter; @Bean - public JwtAuthenticationFilter jwtAuthenticationFilter( - JwtTokenValidator jwtTokenValidator) { - - return new JwtAuthenticationFilter(jwtTokenValidator); - } - - @Bean - public JwtTokenValidator jwtTokenValidator() { - return new JwtTokenValidator(jwtSecret); - } - - @Bean - public SecurityFilterChain securityFilterChain( - HttpSecurity http, - JwtAuthenticationFilter jwtAuthenticationFilter) throws Exception { - + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .csrf(csrf -> csrf.disable()) .cors(cors -> cors.configurationSource(corsConfigurationSource())) @@ -52,8 +34,7 @@ public SecurityFilterChain securityFilterChain( .requestMatchers("/actuator/health").permitAll() .anyRequest().authenticated() ) - .addFilterBefore(jwtAuthenticationFilter, - UsernamePasswordAuthenticationFilter.class); + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } diff --git a/backend/enrollment-service/src/main/java/com/example/enrollmentservice/config/WebClientConfig.java b/backend/enrollment-service/src/main/java/com/example/enrollmentservice/config/WebClientConfig.java index 3d45c9f..0d5a5ac 100644 --- a/backend/enrollment-service/src/main/java/com/example/enrollmentservice/config/WebClientConfig.java +++ b/backend/enrollment-service/src/main/java/com/example/enrollmentservice/config/WebClientConfig.java @@ -11,10 +11,20 @@ public class WebClientConfig { @Value("${student-service.base-url}") private String studentServiceBaseUrl; + @Value("${course-service.base-url}") + private String courseServiceBaseUrl; + @Bean public WebClient studentServiceWebClient() { return WebClient.builder() .baseUrl(studentServiceBaseUrl) .build(); } + + @Bean + public WebClient courseServiceWebClient() { + return WebClient.builder() + .baseUrl(courseServiceBaseUrl) + .build(); + } } diff --git a/backend/enrollment-service/src/main/java/com/example/enrollmentservice/dto/CourseDTO.java b/backend/enrollment-service/src/main/java/com/example/enrollmentservice/dto/CourseDTO.java index a376e9b..6fba260 100644 --- a/backend/enrollment-service/src/main/java/com/example/enrollmentservice/dto/CourseDTO.java +++ b/backend/enrollment-service/src/main/java/com/example/enrollmentservice/dto/CourseDTO.java @@ -1,56 +1,27 @@ package com.example.enrollmentservice.dto; -import com.example.enrollmentservice.entity.Course; -import jakarta.validation.constraints.Min; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; +/** + * Mirrors the data contract exposed by course-service's /api/v1/courses + * endpoint - the same cross-service DTO pattern used for StudentDTO. + * Only the fields enrollment-service actually needs are kept. + */ @Data @Builder @NoArgsConstructor @AllArgsConstructor public class CourseDTO { - private Long id; - - @NotBlank(message = "Course code is required") private String courseCode; - - @NotBlank(message = "Title is required") private String title; - - private String description; - - @NotNull(message = "Credits is required") - @Min(value = 1, message = "Credits must be at least 1") private Integer credits; - - @Min(value = 1, message = "Capacity must be at least 1") private Integer capacity; - - public static CourseDTO fromEntity(Course course) { - return CourseDTO.builder() - .id(course.getId()) - .courseCode(course.getCourseCode()) - .title(course.getTitle()) - .description(course.getDescription()) - .credits(course.getCredits()) - .capacity(course.getCapacity()) - .build(); - } - - public Course toEntity() { - return Course.builder() - .id(this.id) - .courseCode(this.courseCode) - .title(this.title) - .description(this.description) - .credits(this.credits) - .capacity(this.capacity != null ? this.capacity : 30) - .build(); - } + private String semester; + private String instructor; + private String department; + private String status; } diff --git a/backend/enrollment-service/src/main/java/com/example/enrollmentservice/dto/EnrollmentResponseDTO.java b/backend/enrollment-service/src/main/java/com/example/enrollmentservice/dto/EnrollmentResponseDTO.java index a69664a..ff55aac 100644 --- a/backend/enrollment-service/src/main/java/com/example/enrollmentservice/dto/EnrollmentResponseDTO.java +++ b/backend/enrollment-service/src/main/java/com/example/enrollmentservice/dto/EnrollmentResponseDTO.java @@ -10,8 +10,9 @@ /** * Aggregated view returned by enrollment-service: enrollment status plus - * the course info owned locally and (optionally) a snapshot of student - * info fetched from student-service at read time. + * a snapshot of student info (from student-service) and course info + * (from course-service), both fetched at read time via their respective + * clients rather than owned locally. */ @Data @Builder @@ -21,18 +22,24 @@ public class EnrollmentResponseDTO { private Long id; private Long studentId; private StudentDTO student; + private Long courseId; private CourseDTO course; private String status; + + /** @deprecated see grade-service for the authoritative grade + GPA/CGPA calculation. */ + @Deprecated private Double grade; + private LocalDateTime enrolledAt; private LocalDateTime updatedAt; - public static EnrollmentResponseDTO fromEntity(Enrollment enrollment, StudentDTO student) { + public static EnrollmentResponseDTO fromEntity(Enrollment enrollment, StudentDTO student, CourseDTO course) { return EnrollmentResponseDTO.builder() .id(enrollment.getId()) .studentId(enrollment.getStudentId()) .student(student) - .course(CourseDTO.fromEntity(enrollment.getCourse())) + .courseId(enrollment.getCourseId()) + .course(course) .status(enrollment.getStatus().name()) .grade(enrollment.getGrade()) .enrolledAt(enrollment.getEnrolledAt()) diff --git a/backend/enrollment-service/src/main/java/com/example/enrollmentservice/entity/Enrollment.java b/backend/enrollment-service/src/main/java/com/example/enrollmentservice/entity/Enrollment.java index 2db8059..8f6de9d 100644 --- a/backend/enrollment-service/src/main/java/com/example/enrollmentservice/entity/Enrollment.java +++ b/backend/enrollment-service/src/main/java/com/example/enrollmentservice/entity/Enrollment.java @@ -10,12 +10,14 @@ /** * Represents the enrollment lifecycle of a student in a course. - * studentId is a reference only (no FK) - the student's own record - * lives in student-service; this is the boundary of the bounded context. + * Both studentId and courseId are references only (no FK, no JPA + * relation) - the student record lives in student-service and the + * course record lives in course-service. This is the bounded-context + * boundary: enrollment-service owns only the enrollment fact itself. */ @Entity @Table(name = "enrollments", uniqueConstraints = { - @UniqueConstraint(name = "uk_student_course", columnNames = {"studentId", "course_id"}) + @UniqueConstraint(name = "uk_student_course", columnNames = {"studentId", "courseId"}) }) @Data @Builder @@ -31,15 +33,21 @@ public class Enrollment { @Column(nullable = false) private Long studentId; - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "course_id", nullable = false) - private Course course; + /** Reference to the course owned by course-service. */ + @Column(nullable = false) + private Long courseId; @Enumerated(EnumType.STRING) @Column(nullable = false, length = 20) @Builder.Default private EnrollmentStatus status = EnrollmentStatus.PENDING; + /** + * @deprecated retained for backward compatibility with existing rows + * and the legacy PATCH /enrollments/{id}/grade endpoint. grade-service + * is now the authoritative source for grades and GPA/CGPA calculation. + */ + @Deprecated private Double grade; @Column(nullable = false) diff --git a/backend/enrollment-service/src/main/java/com/example/enrollmentservice/exception/GlobalExceptionHandler.java b/backend/enrollment-service/src/main/java/com/example/enrollmentservice/exception/GlobalExceptionHandler.java index 52e1f93..ea880ef 100644 --- a/backend/enrollment-service/src/main/java/com/example/enrollmentservice/exception/GlobalExceptionHandler.java +++ b/backend/enrollment-service/src/main/java/com/example/enrollmentservice/exception/GlobalExceptionHandler.java @@ -36,8 +36,8 @@ public ResponseEntity handleCapacity(CourseCapacityExceededExcept return build(HttpStatus.CONFLICT, "Course Full", ex.getMessage(), req, null); } - @ExceptionHandler(StudentServiceUnavailableException.class) - public ResponseEntity handleUnavailable(StudentServiceUnavailableException ex, HttpServletRequest req) { + @ExceptionHandler(UpstreamServiceUnavailableException.class) + public ResponseEntity handleUnavailable(UpstreamServiceUnavailableException ex, HttpServletRequest req) { return build(HttpStatus.SERVICE_UNAVAILABLE, "Dependency Unavailable", ex.getMessage(), req, null); } diff --git a/backend/enrollment-service/src/main/java/com/example/enrollmentservice/exception/StudentServiceUnavailableException.java b/backend/enrollment-service/src/main/java/com/example/enrollmentservice/exception/StudentServiceUnavailableException.java index ee282c5..7a81827 100644 --- a/backend/enrollment-service/src/main/java/com/example/enrollmentservice/exception/StudentServiceUnavailableException.java +++ b/backend/enrollment-service/src/main/java/com/example/enrollmentservice/exception/StudentServiceUnavailableException.java @@ -1,7 +1,12 @@ package com.example.enrollmentservice.exception; -/** Thrown when enrollment-service cannot reach student-service to validate a student. */ -public class StudentServiceUnavailableException extends RuntimeException { +/** + * @deprecated use {@link UpstreamServiceUnavailableException} instead, + * which covers both student-service and course-service call failures. + * Kept only so any external code referencing this class name still compiles. + */ +@Deprecated +public class StudentServiceUnavailableException extends UpstreamServiceUnavailableException { public StudentServiceUnavailableException(String message) { super(message); } diff --git a/backend/enrollment-service/src/main/java/com/example/enrollmentservice/exception/UpstreamServiceUnavailableException.java b/backend/enrollment-service/src/main/java/com/example/enrollmentservice/exception/UpstreamServiceUnavailableException.java new file mode 100644 index 0000000..257efe8 --- /dev/null +++ b/backend/enrollment-service/src/main/java/com/example/enrollmentservice/exception/UpstreamServiceUnavailableException.java @@ -0,0 +1,13 @@ +package com.example.enrollmentservice.exception; + +/** + * Thrown when a call to any upstream dependency (student-service or + * course-service) fails or times out. Replaces the narrower, older + * StudentServiceUnavailableException, which is retained only as a + * deprecated subclass for source compatibility. + */ +public class UpstreamServiceUnavailableException extends RuntimeException { + public UpstreamServiceUnavailableException(String message) { + super(message); + } +} diff --git a/backend/enrollment-service/src/main/java/com/example/enrollmentservice/repository/CourseRepository.java b/backend/enrollment-service/src/main/java/com/example/enrollmentservice/repository/CourseRepository.java deleted file mode 100644 index 68fda2d..0000000 --- a/backend/enrollment-service/src/main/java/com/example/enrollmentservice/repository/CourseRepository.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.example.enrollmentservice.repository; - -import com.example.enrollmentservice.entity.Course; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - -import java.util.Optional; - -@Repository -public interface CourseRepository extends JpaRepository { - Optional findByCourseCode(String courseCode); - boolean existsByCourseCode(String courseCode); -} diff --git a/backend/enrollment-service/src/main/java/com/example/enrollmentservice/security/JwtAuthenticationFilter.java b/backend/enrollment-service/src/main/java/com/example/enrollmentservice/security/JwtAuthenticationFilter.java index 533fca5..c84a408 100644 --- a/backend/enrollment-service/src/main/java/com/example/enrollmentservice/security/JwtAuthenticationFilter.java +++ b/backend/enrollment-service/src/main/java/com/example/enrollmentservice/security/JwtAuthenticationFilter.java @@ -18,7 +18,7 @@ import java.io.IOException; import java.util.List; - +@Component @RequiredArgsConstructor public class JwtAuthenticationFilter extends OncePerRequestFilter { diff --git a/backend/enrollment-service/src/main/java/com/example/enrollmentservice/service/EnrollmentService.java b/backend/enrollment-service/src/main/java/com/example/enrollmentservice/service/EnrollmentService.java index ffc95c2..7f78df6 100644 --- a/backend/enrollment-service/src/main/java/com/example/enrollmentservice/service/EnrollmentService.java +++ b/backend/enrollment-service/src/main/java/com/example/enrollmentservice/service/EnrollmentService.java @@ -2,16 +2,16 @@ import com.example.common.security.JwtPrincipal; import com.example.common.security.SecurityUtils; +import com.example.enrollmentservice.client.CourseClient; import com.example.enrollmentservice.client.StudentClient; +import com.example.enrollmentservice.dto.CourseDTO; import com.example.enrollmentservice.dto.EnrollmentRequestDTO; import com.example.enrollmentservice.dto.EnrollmentResponseDTO; import com.example.enrollmentservice.dto.StudentDTO; -import com.example.enrollmentservice.entity.Course; import com.example.enrollmentservice.entity.Enrollment; import com.example.enrollmentservice.exception.CourseCapacityExceededException; import com.example.enrollmentservice.exception.DuplicateResourceException; import com.example.enrollmentservice.exception.ResourceNotFoundException; -import com.example.enrollmentservice.repository.CourseRepository; import com.example.enrollmentservice.repository.EnrollmentRepository; import lombok.RequiredArgsConstructor; import org.springframework.security.access.AccessDeniedException; @@ -23,15 +23,19 @@ /** * Orchestrates the student <-> course enrollment lifecycle. * + * As of Phase 3, enrollment-service owns only the enrollment fact itself + * (studentId + courseId + status/grade) - both the student profile and + * the course catalog live in their own services and are fetched here via + * StudentClient / CourseClient, each forwarding the caller's own bearer + * token downstream (pass-through auth) so ownership rules apply + * identically no matter which service the caller originally hit. + * * Access rules enforced here (in addition to @PreAuthorize on the * controller for admin-only actions): * - A STUDENT may only enroll THEMSELVES (their token's studentId is used, * overriding whatever studentId was sent in the request body) and may * only view/drop their OWN enrollments. * - An ADMIN may enroll/view/drop on behalf of any student. - * - * Every call to student-service forwards the original caller's bearer - * token, so student-service applies the exact same ownership rule. */ @Service @RequiredArgsConstructor @@ -39,59 +43,41 @@ public class EnrollmentService { private final EnrollmentRepository enrollmentRepository; - private final CourseRepository courseRepository; private final StudentClient studentClient; + private final CourseClient courseClient; public EnrollmentResponseDTO enrollStudent(EnrollmentRequestDTO request, String bearerToken) { JwtPrincipal principal = requirePrincipal(); - // Determine the effective student ID exactly once - final Long effectiveStudentId; - - if (principal.isAdmin()) { - effectiveStudentId = request.getStudentId(); - } else { - effectiveStudentId = principal.getStudentId(); - } - - Course course = courseRepository.findById(request.getCourseId()) - .orElseThrow(() -> new ResourceNotFoundException( - "Course not found with id: " + request.getCourseId())); + // A STUDENT can only ever enroll themselves - the token's studentId + // is authoritative, regardless of what the request body claims. + Long effectiveStudentId = principal.isAdmin() ? request.getStudentId() : principal.getStudentId(); - long confirmedCount = enrollmentRepository.countByCourseIdAndStatus( - course.getId(), - Enrollment.EnrollmentStatus.CONFIRMED); + CourseDTO course = courseClient.getCourseById(request.getCourseId(), bearerToken) + .orElseThrow(() -> new ResourceNotFoundException("Course not found with id: " + request.getCourseId())); - if (confirmedCount >= course.getCapacity()) { + long confirmedCount = enrollmentRepository.countByCourseIdAndStatus(course.getId(), Enrollment.EnrollmentStatus.CONFIRMED); + if (course.getCapacity() != null && confirmedCount >= course.getCapacity()) { throw new CourseCapacityExceededException( - "Course '" + course.getCourseCode() - + "' has reached its capacity of " - + course.getCapacity()); + "Course '" + course.getCourseCode() + "' has reached its capacity of " + course.getCapacity()); } StudentDTO student = studentClient.getStudentById(effectiveStudentId, bearerToken) - .orElseThrow(() -> new ResourceNotFoundException( - "Student not found with id: " + effectiveStudentId)); - - if (enrollmentRepository.existsByStudentIdAndCourseId( - effectiveStudentId, - request.getCourseId())) { + .orElseThrow(() -> new ResourceNotFoundException("Student not found with id: " + effectiveStudentId)); + if (enrollmentRepository.existsByStudentIdAndCourseId(effectiveStudentId, request.getCourseId())) { throw new DuplicateResourceException( - "Student " + effectiveStudentId - + " is already enrolled in course " - + request.getCourseId()); + "Student " + effectiveStudentId + " is already enrolled in course " + request.getCourseId()); } Enrollment enrollment = Enrollment.builder() .studentId(effectiveStudentId) - .course(course) + .courseId(course.getId()) .status(Enrollment.EnrollmentStatus.CONFIRMED) .build(); Enrollment saved = enrollmentRepository.save(enrollment); - - return EnrollmentResponseDTO.fromEntity(saved, student); + return EnrollmentResponseDTO.fromEntity(saved, student, course); } @Transactional(readOnly = true) @@ -100,7 +86,8 @@ public EnrollmentResponseDTO getEnrollmentById(Long id, String bearerToken) { .orElseThrow(() -> new ResourceNotFoundException("Enrollment not found with id: " + id)); requireAdminOrOwner(enrollment.getStudentId()); StudentDTO student = studentClient.getStudentById(enrollment.getStudentId(), bearerToken).orElse(null); - return EnrollmentResponseDTO.fromEntity(enrollment, student); + CourseDTO course = courseClient.getCourseById(enrollment.getCourseId(), bearerToken).orElse(null); + return EnrollmentResponseDTO.fromEntity(enrollment, student, course); } @Transactional(readOnly = true) @@ -109,17 +96,21 @@ public List getEnrollmentsByStudent(Long studentId, Strin StudentDTO student = studentClient.getStudentById(studentId, bearerToken) .orElseThrow(() -> new ResourceNotFoundException("Student not found with id: " + studentId)); return enrollmentRepository.findByStudentId(studentId).stream() - .map(e -> EnrollmentResponseDTO.fromEntity(e, student)) + .map(e -> { + CourseDTO course = courseClient.getCourseById(e.getCourseId(), bearerToken).orElse(null); + return EnrollmentResponseDTO.fromEntity(e, student, course); + }) .toList(); } /** ADMIN only - see EnrollmentController's @PreAuthorize. */ @Transactional(readOnly = true) public List getEnrollmentsByCourse(Long courseId, String bearerToken) { + CourseDTO course = courseClient.getCourseById(courseId, bearerToken).orElse(null); return enrollmentRepository.findByCourseId(courseId).stream() .map(e -> { StudentDTO student = studentClient.getStudentById(e.getStudentId(), bearerToken).orElse(null); - return EnrollmentResponseDTO.fromEntity(e, student); + return EnrollmentResponseDTO.fromEntity(e, student, course); }) .toList(); } @@ -131,10 +122,16 @@ public EnrollmentResponseDTO updateStatus(Long enrollmentId, String status, Stri enrollment.setStatus(Enrollment.EnrollmentStatus.valueOf(status.toUpperCase())); Enrollment saved = enrollmentRepository.save(enrollment); StudentDTO student = studentClient.getStudentById(saved.getStudentId(), bearerToken).orElse(null); - return EnrollmentResponseDTO.fromEntity(saved, student); + CourseDTO course = courseClient.getCourseById(saved.getCourseId(), bearerToken).orElse(null); + return EnrollmentResponseDTO.fromEntity(saved, student, course); } - /** ADMIN only - see EnrollmentController's @PreAuthorize. */ + /** + * @deprecated grade-service now owns grade assignment and GPA/CGPA + * calculation. This is retained only for backward compatibility with + * existing integrations against the original enrollment-service API. + */ + @Deprecated public EnrollmentResponseDTO recordGrade(Long enrollmentId, Double grade, String bearerToken) { Enrollment enrollment = enrollmentRepository.findById(enrollmentId) .orElseThrow(() -> new ResourceNotFoundException("Enrollment not found with id: " + enrollmentId)); @@ -142,7 +139,8 @@ public EnrollmentResponseDTO recordGrade(Long enrollmentId, Double grade, String enrollment.setStatus(Enrollment.EnrollmentStatus.COMPLETED); Enrollment saved = enrollmentRepository.save(enrollment); StudentDTO student = studentClient.getStudentById(saved.getStudentId(), bearerToken).orElse(null); - return EnrollmentResponseDTO.fromEntity(saved, student); + CourseDTO course = courseClient.getCourseById(saved.getCourseId(), bearerToken).orElse(null); + return EnrollmentResponseDTO.fromEntity(saved, student, course); } public void dropEnrollment(Long enrollmentId) { diff --git a/backend/enrollment-service/src/main/resources/application.properties b/backend/enrollment-service/src/main/resources/application.properties index 0e9c3b6..e91d1d7 100644 --- a/backend/enrollment-service/src/main/resources/application.properties +++ b/backend/enrollment-service/src/main/resources/application.properties @@ -17,6 +17,9 @@ spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQLDialect # Base URL of student-service. In docker-compose this resolves via the service name. student-service.base-url=${STUDENT_SERVICE_URL:http://localhost:8081} +# Base URL of course-service (Phase 3: course ownership moved out of enrollment-service). +course-service.base-url=${COURSE_SERVICE_URL:http://localhost:8083} + # ---- JWT (must match auth-service's jwt.secret for token validation to succeed) ---- jwt.secret=${JWT_SECRET:MzM0NmM4NDNhOWM5NDcyM2FhZmY3NmY2ZGRhYTQ5ZjM0Njc4OTBhYmNkZWYxMjM0NTY3ODkwYWJjZGVm} diff --git a/backend/grade-service/.gitignore b/backend/grade-service/.gitignore new file mode 100644 index 0000000..6255ee4 --- /dev/null +++ b/backend/grade-service/.gitignore @@ -0,0 +1,13 @@ +target/ +*.class +*.jar +!.mvn/wrapper/maven-wrapper.jar +.idea/ +*.iml +.vscode/ +.DS_Store +*.log +HELP.md +.mvn/ +mvnw +mvnw.cmd diff --git a/backend/grade-service/Dockerfile b/backend/grade-service/Dockerfile new file mode 100644 index 0000000..f0ac253 --- /dev/null +++ b/backend/grade-service/Dockerfile @@ -0,0 +1,18 @@ +# ---- Build stage ---- +FROM maven:3.9-eclipse-temurin-17 AS build +WORKDIR /app + +COPY common-lib ./common-lib +RUN cd common-lib && mvn -B clean install -DskipTests + +COPY grade-service/pom.xml ./grade-service/pom.xml +RUN cd grade-service && mvn -B dependency:go-offline +COPY grade-service/src ./grade-service/src +RUN cd grade-service && mvn -B clean package -DskipTests + +# ---- Run stage ---- +FROM eclipse-temurin:17-jre-alpine +WORKDIR /app +COPY --from=build /app/grade-service/target/*.jar app.jar +EXPOSE 8084 +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/backend/grade-service/pom.xml b/backend/grade-service/pom.xml new file mode 100644 index 0000000..4ffa87b --- /dev/null +++ b/backend/grade-service/pom.xml @@ -0,0 +1,102 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.3.4 + + + + com.example + grade-service + 1.0.0 + grade-service + Grade microservice - grade assignment, semester-wise grades, GPA/CGPA calculation + + + 17 + + + + + com.example + common-lib + 1.0.0 + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-webflux + + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-actuator + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.6.0 + + + + com.mysql + mysql-connector-j + runtime + + + org.projectlombok + lombok + true + + + org.springframework.boot + spring-boot-starter-test + test + + + com.h2database + h2 + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + + diff --git a/backend/grade-service/src/main/java/com/example/gradeservice/GradeServiceApplication.java b/backend/grade-service/src/main/java/com/example/gradeservice/GradeServiceApplication.java new file mode 100644 index 0000000..5d92a70 --- /dev/null +++ b/backend/grade-service/src/main/java/com/example/gradeservice/GradeServiceApplication.java @@ -0,0 +1,20 @@ +package com.example.gradeservice; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * Entry point for the Grade Service. Owns grade assignment and + * GPA/CGPA calculation. An ADMIN assigns a grade against an existing + * enrollment (verified via enrollment-service); credits are snapshotted + * from course-service at assignment time so GPA math stays correct even + * if a course's credit value changes later. A STUDENT can only view + * their own grades and GPA. + */ +@SpringBootApplication +public class GradeServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(GradeServiceApplication.class, args); + } +} diff --git a/backend/grade-service/src/main/java/com/example/gradeservice/client/CourseClient.java b/backend/grade-service/src/main/java/com/example/gradeservice/client/CourseClient.java new file mode 100644 index 0000000..d6c2fbe --- /dev/null +++ b/backend/grade-service/src/main/java/com/example/gradeservice/client/CourseClient.java @@ -0,0 +1,42 @@ +package com.example.gradeservice.client; + +import com.example.gradeservice.dto.CourseDTO; +import com.example.gradeservice.exception.UpstreamServiceUnavailableException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientResponseException; + +import java.time.Duration; +import java.util.Optional; + +@Component +@RequiredArgsConstructor +@Slf4j +public class CourseClient { + + private final WebClient courseServiceWebClient; + + public Optional getCourseById(Long courseId, String bearerToken) { + try { + CourseDTO course = courseServiceWebClient.get() + .uri("/api/v1/courses/{id}", courseId) + .header(HttpHeaders.AUTHORIZATION, bearerToken) + .retrieve() + .bodyToMono(CourseDTO.class) + .timeout(Duration.ofSeconds(5)) + .block(); + return Optional.ofNullable(course); + } catch (WebClientResponseException.NotFound ex) { + return Optional.empty(); + } catch (WebClientResponseException.Forbidden | WebClientResponseException.Unauthorized ex) { + throw ex; + } catch (Exception ex) { + log.error("Failed to reach course-service for id {}: {}", courseId, ex.getMessage()); + throw new UpstreamServiceUnavailableException( + "Unable to verify course " + courseId + " - course-service is unreachable"); + } + } +} diff --git a/backend/grade-service/src/main/java/com/example/gradeservice/client/EnrollmentClient.java b/backend/grade-service/src/main/java/com/example/gradeservice/client/EnrollmentClient.java new file mode 100644 index 0000000..7549c24 --- /dev/null +++ b/backend/grade-service/src/main/java/com/example/gradeservice/client/EnrollmentClient.java @@ -0,0 +1,47 @@ +package com.example.gradeservice.client; + +import com.example.gradeservice.dto.EnrollmentDTO; +import com.example.gradeservice.exception.UpstreamServiceUnavailableException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientResponseException; + +import java.time.Duration; +import java.util.Optional; + +/** + * Encapsulates all HTTP calls from grade-service to enrollment-service. + * Forwards the caller's own bearer token downstream (pass-through auth), + * same pattern as StudentClient/CourseClient in enrollment-service. + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class EnrollmentClient { + + private final WebClient enrollmentServiceWebClient; + + public Optional getEnrollmentById(Long enrollmentId, String bearerToken) { + try { + EnrollmentDTO enrollment = enrollmentServiceWebClient.get() + .uri("/api/v1/enrollments/{id}", enrollmentId) + .header(HttpHeaders.AUTHORIZATION, bearerToken) + .retrieve() + .bodyToMono(EnrollmentDTO.class) + .timeout(Duration.ofSeconds(5)) + .block(); + return Optional.ofNullable(enrollment); + } catch (WebClientResponseException.NotFound ex) { + return Optional.empty(); + } catch (WebClientResponseException.Forbidden | WebClientResponseException.Unauthorized ex) { + throw ex; + } catch (Exception ex) { + log.error("Failed to reach enrollment-service for id {}: {}", enrollmentId, ex.getMessage()); + throw new UpstreamServiceUnavailableException( + "Unable to verify enrollment " + enrollmentId + " - enrollment-service is unreachable"); + } + } +} diff --git a/backend/grade-service/src/main/java/com/example/gradeservice/config/JwtConfig.java b/backend/grade-service/src/main/java/com/example/gradeservice/config/JwtConfig.java new file mode 100644 index 0000000..4308d64 --- /dev/null +++ b/backend/grade-service/src/main/java/com/example/gradeservice/config/JwtConfig.java @@ -0,0 +1,19 @@ +package com.example.gradeservice.config; + +import com.example.common.security.JwtTokenValidator; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** Kept separate from SecurityConfig to avoid a circular bean dependency - see student-service's JwtConfig for the full explanation. */ +@Configuration +public class JwtConfig { + + @Value("${jwt.secret}") + private String jwtSecret; + + @Bean + public JwtTokenValidator jwtTokenValidator() { + return new JwtTokenValidator(jwtSecret); + } +} diff --git a/backend/grade-service/src/main/java/com/example/gradeservice/config/SecurityConfig.java b/backend/grade-service/src/main/java/com/example/gradeservice/config/SecurityConfig.java new file mode 100644 index 0000000..f91e59c --- /dev/null +++ b/backend/grade-service/src/main/java/com/example/gradeservice/config/SecurityConfig.java @@ -0,0 +1,54 @@ +package com.example.gradeservice.config; + +import com.example.gradeservice.security.JwtAuthenticationFilter; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.List; + +@Configuration +@EnableWebSecurity +@EnableMethodSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .csrf(csrf -> csrf.disable()) + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/actuator/health", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll() + .anyRequest().authenticated() + ) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOriginPatterns(List.of("*")); + configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); + configuration.setAllowedHeaders(List.of("*")); + configuration.setAllowCredentials(true); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } +} diff --git a/backend/grade-service/src/main/java/com/example/gradeservice/config/WebClientConfig.java b/backend/grade-service/src/main/java/com/example/gradeservice/config/WebClientConfig.java new file mode 100644 index 0000000..8a1f30d --- /dev/null +++ b/backend/grade-service/src/main/java/com/example/gradeservice/config/WebClientConfig.java @@ -0,0 +1,30 @@ +package com.example.gradeservice.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.reactive.function.client.WebClient; + +@Configuration +public class WebClientConfig { + + @Value("${course-service.base-url}") + private String courseServiceBaseUrl; + + @Value("${enrollment-service.base-url:http://localhost:8082}") + private String enrollmentServiceBaseUrl; + + @Bean + public WebClient courseServiceWebClient() { + return WebClient.builder() + .baseUrl(courseServiceBaseUrl) + .build(); + } + + @Bean + public WebClient enrollmentServiceWebClient() { + return WebClient.builder() + .baseUrl(enrollmentServiceBaseUrl) + .build(); + } +} diff --git a/backend/grade-service/src/main/java/com/example/gradeservice/controller/GradeController.java b/backend/grade-service/src/main/java/com/example/gradeservice/controller/GradeController.java new file mode 100644 index 0000000..6bb5579 --- /dev/null +++ b/backend/grade-service/src/main/java/com/example/gradeservice/controller/GradeController.java @@ -0,0 +1,53 @@ +package com.example.gradeservice.controller; + +import com.example.gradeservice.dto.GradeAssignRequestDTO; +import com.example.gradeservice.dto.GradeResponseDTO; +import com.example.gradeservice.dto.GpaResponseDTO; +import com.example.gradeservice.service.GradeService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * Access rules: + * - POST /grades: ADMIN only. + * - GET by student, GET GPA: ADMIN or the owning STUDENT only (enforced + * in GradeService). + */ +@RestController +@RequestMapping("/api/v1/grades") +@RequiredArgsConstructor +@Tag(name = "Grades", description = "Grade assignment, semester-wise grades, and GPA/CGPA calculation") +public class GradeController { + + private final GradeService gradeService; + + @PostMapping + @PreAuthorize("hasRole('ADMIN')") + @Operation(summary = "Assign (or update) a grade for an existing enrollment - ADMIN only") + public ResponseEntity assignGrade(@Valid @RequestBody GradeAssignRequestDTO request, + @RequestHeader(HttpHeaders.AUTHORIZATION) String authHeader) { + return new ResponseEntity<>(gradeService.assignGrade(request, authHeader), HttpStatus.CREATED); + } + + @GetMapping("/student/{studentId}") + @Operation(summary = "List all grades for a student (ADMIN or the owning student)") + public ResponseEntity> getGradesByStudent(@PathVariable Long studentId, + @RequestHeader(HttpHeaders.AUTHORIZATION) String authHeader) { + return ResponseEntity.ok(gradeService.getGradesByStudent(studentId, authHeader)); + } + + @GetMapping("/student/{studentId}/gpa") + @Operation(summary = "CGPA plus semester-wise GPA breakdown (ADMIN or the owning student)") + public ResponseEntity getGpa(@PathVariable Long studentId) { + return ResponseEntity.ok(gradeService.getGpa(studentId)); + } +} diff --git a/backend/grade-service/src/main/java/com/example/gradeservice/dto/CourseDTO.java b/backend/grade-service/src/main/java/com/example/gradeservice/dto/CourseDTO.java new file mode 100644 index 0000000..a79555c --- /dev/null +++ b/backend/grade-service/src/main/java/com/example/gradeservice/dto/CourseDTO.java @@ -0,0 +1,18 @@ +package com.example.gradeservice.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CourseDTO { + private Long id; + private String courseCode; + private String title; + private Integer credits; + private String semester; +} diff --git a/backend/grade-service/src/main/java/com/example/gradeservice/dto/EnrollmentDTO.java b/backend/grade-service/src/main/java/com/example/gradeservice/dto/EnrollmentDTO.java new file mode 100644 index 0000000..2d3a063 --- /dev/null +++ b/backend/grade-service/src/main/java/com/example/gradeservice/dto/EnrollmentDTO.java @@ -0,0 +1,22 @@ +package com.example.gradeservice.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Mirrors the fields grade-service needs from enrollment-service's + * EnrollmentResponseDTO - the same cross-service DTO pattern used + * throughout this system (see StudentDTO / CourseDTO in enrollment-service). + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class EnrollmentDTO { + private Long id; + private Long studentId; + private Long courseId; + private String status; +} diff --git a/backend/grade-service/src/main/java/com/example/gradeservice/dto/GpaResponseDTO.java b/backend/grade-service/src/main/java/com/example/gradeservice/dto/GpaResponseDTO.java new file mode 100644 index 0000000..9d427fb --- /dev/null +++ b/backend/grade-service/src/main/java/com/example/gradeservice/dto/GpaResponseDTO.java @@ -0,0 +1,23 @@ +package com.example.gradeservice.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Map; + +/** + * Overall CGPA plus a semester-wise GPA breakdown, both computed as a + * credit-weighted average: sum(gradePoints * credits) / sum(credits). + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class GpaResponseDTO { + private Long studentId; + private Double cgpa; + private Integer totalCredits; + private Map gpaBySemester; +} diff --git a/backend/grade-service/src/main/java/com/example/gradeservice/dto/GradeAssignRequestDTO.java b/backend/grade-service/src/main/java/com/example/gradeservice/dto/GradeAssignRequestDTO.java new file mode 100644 index 0000000..0cc3d78 --- /dev/null +++ b/backend/grade-service/src/main/java/com/example/gradeservice/dto/GradeAssignRequestDTO.java @@ -0,0 +1,30 @@ +package com.example.gradeservice.dto; + +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * An ADMIN assigns a grade against an existing enrollment (not raw + * studentId/courseId) - this guarantees a grade can only be recorded for + * a real, verified enrollment, and studentId/courseId/semester/credits + * are all derived server-side from that enrollment and its course. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class GradeAssignRequestDTO { + + @NotNull(message = "enrollmentId is required") + private Long enrollmentId; + + @NotNull(message = "gradePoints is required") + @DecimalMin(value = "0.0", message = "gradePoints must be between 0 and 10") + @DecimalMax(value = "10.0", message = "gradePoints must be between 0 and 10") + private Double gradePoints; +} diff --git a/backend/grade-service/src/main/java/com/example/gradeservice/dto/GradeResponseDTO.java b/backend/grade-service/src/main/java/com/example/gradeservice/dto/GradeResponseDTO.java new file mode 100644 index 0000000..aef6279 --- /dev/null +++ b/backend/grade-service/src/main/java/com/example/gradeservice/dto/GradeResponseDTO.java @@ -0,0 +1,43 @@ +package com.example.gradeservice.dto; + +import com.example.gradeservice.entity.Grade; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class GradeResponseDTO { + private Long id; + private Long studentId; + private Long courseId; + private String courseCode; + private String courseTitle; + private Long enrollmentId; + private String semester; + private Integer credits; + private Double gradePoints; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + public static GradeResponseDTO fromEntity(Grade grade, CourseDTO course) { + return GradeResponseDTO.builder() + .id(grade.getId()) + .studentId(grade.getStudentId()) + .courseId(grade.getCourseId()) + .courseCode(course != null ? course.getCourseCode() : null) + .courseTitle(course != null ? course.getTitle() : null) + .enrollmentId(grade.getEnrollmentId()) + .semester(grade.getSemester()) + .credits(grade.getCredits()) + .gradePoints(grade.getGradePoints()) + .createdAt(grade.getCreatedAt()) + .updatedAt(grade.getUpdatedAt()) + .build(); + } +} diff --git a/backend/grade-service/src/main/java/com/example/gradeservice/entity/Grade.java b/backend/grade-service/src/main/java/com/example/gradeservice/entity/Grade.java new file mode 100644 index 0000000..558674a --- /dev/null +++ b/backend/grade-service/src/main/java/com/example/gradeservice/entity/Grade.java @@ -0,0 +1,70 @@ +package com.example.gradeservice.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * A finalized grade for one student in one course. References + * (studentId, courseId, enrollmentId) point at rows owned by + * student-service, course-service, and enrollment-service respectively - + * grade-service does not hold foreign keys into another service's + * database. `credits` and `semester` are snapshotted from course-service + * at assignment time so GPA calculations remain stable even if a + * course's credit value changes afterward. + */ +@Entity +@Table(name = "grades", uniqueConstraints = { + @UniqueConstraint(name = "uk_student_course_grade", columnNames = {"studentId", "courseId"}) +}) +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Grade { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long studentId; + + @Column(nullable = false) + private Long courseId; + + @Column(nullable = false) + private Long enrollmentId; + + /** Snapshotted from course-service at assignment time (e.g. "FALL2026"). */ + @Column(length = 20) + private String semester; + + /** Snapshotted from course-service at assignment time. */ + @Column(nullable = false) + private Integer credits; + + /** 0.0 - 10.0 grade-point scale. */ + @Column(nullable = false) + private Double gradePoints; + + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt; + + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + this.createdAt = LocalDateTime.now(); + this.updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + this.updatedAt = LocalDateTime.now(); + } +} diff --git a/backend/grade-service/src/main/java/com/example/gradeservice/exception/ErrorResponse.java b/backend/grade-service/src/main/java/com/example/gradeservice/exception/ErrorResponse.java new file mode 100644 index 0000000..fc3dd41 --- /dev/null +++ b/backend/grade-service/src/main/java/com/example/gradeservice/exception/ErrorResponse.java @@ -0,0 +1,24 @@ +package com.example.gradeservice.exception; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.Map; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ErrorResponse { + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") + private LocalDateTime timestamp; + private int status; + private String error; + private String message; + private String path; + private Map validationErrors; +} diff --git a/backend/grade-service/src/main/java/com/example/gradeservice/exception/GlobalExceptionHandler.java b/backend/grade-service/src/main/java/com/example/gradeservice/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..30b01d5 --- /dev/null +++ b/backend/grade-service/src/main/java/com/example/gradeservice/exception/GlobalExceptionHandler.java @@ -0,0 +1,65 @@ +package com.example.gradeservice.exception; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(AccessDeniedException.class) + public ResponseEntity handleAccessDenied(AccessDeniedException ex, HttpServletRequest req) { + return build(HttpStatus.FORBIDDEN, "Forbidden", "You do not have permission to access this resource", req, null); + } + + @ExceptionHandler(ResourceNotFoundException.class) + public ResponseEntity handleNotFound(ResourceNotFoundException ex, HttpServletRequest req) { + return build(HttpStatus.NOT_FOUND, "Not Found", ex.getMessage(), req, null); + } + + @ExceptionHandler(InvalidEnrollmentException.class) + public ResponseEntity handleInvalidEnrollment(InvalidEnrollmentException ex, HttpServletRequest req) { + return build(HttpStatus.BAD_REQUEST, "Invalid Enrollment", ex.getMessage(), req, null); + } + + @ExceptionHandler(UpstreamServiceUnavailableException.class) + public ResponseEntity handleUnavailable(UpstreamServiceUnavailableException ex, HttpServletRequest req) { + return build(HttpStatus.SERVICE_UNAVAILABLE, "Dependency Unavailable", ex.getMessage(), req, null); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidation(MethodArgumentNotValidException ex, HttpServletRequest req) { + Map validationErrors = new HashMap<>(); + for (FieldError fieldError : ex.getBindingResult().getFieldErrors()) { + validationErrors.put(fieldError.getField(), fieldError.getDefaultMessage()); + } + return build(HttpStatus.BAD_REQUEST, "Validation Failed", "One or more fields are invalid", req, validationErrors); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleGeneric(Exception ex, HttpServletRequest req) { + return build(HttpStatus.INTERNAL_SERVER_ERROR, "Internal Server Error", ex.getMessage(), req, null); + } + + private ResponseEntity build(HttpStatus status, String error, String message, + HttpServletRequest req, Map validationErrors) { + ErrorResponse body = ErrorResponse.builder() + .timestamp(LocalDateTime.now()) + .status(status.value()) + .error(error) + .message(message) + .path(req.getRequestURI()) + .validationErrors(validationErrors) + .build(); + return new ResponseEntity<>(body, status); + } +} diff --git a/backend/grade-service/src/main/java/com/example/gradeservice/exception/InvalidEnrollmentException.java b/backend/grade-service/src/main/java/com/example/gradeservice/exception/InvalidEnrollmentException.java new file mode 100644 index 0000000..8709fd5 --- /dev/null +++ b/backend/grade-service/src/main/java/com/example/gradeservice/exception/InvalidEnrollmentException.java @@ -0,0 +1,8 @@ +package com.example.gradeservice.exception; + +/** Thrown when trying to assign a grade against an enrollment that isn't in a gradable state. */ +public class InvalidEnrollmentException extends RuntimeException { + public InvalidEnrollmentException(String message) { + super(message); + } +} diff --git a/backend/grade-service/src/main/java/com/example/gradeservice/exception/ResourceNotFoundException.java b/backend/grade-service/src/main/java/com/example/gradeservice/exception/ResourceNotFoundException.java new file mode 100644 index 0000000..59f435a --- /dev/null +++ b/backend/grade-service/src/main/java/com/example/gradeservice/exception/ResourceNotFoundException.java @@ -0,0 +1,7 @@ +package com.example.gradeservice.exception; + +public class ResourceNotFoundException extends RuntimeException { + public ResourceNotFoundException(String message) { + super(message); + } +} diff --git a/backend/grade-service/src/main/java/com/example/gradeservice/exception/UpstreamServiceUnavailableException.java b/backend/grade-service/src/main/java/com/example/gradeservice/exception/UpstreamServiceUnavailableException.java new file mode 100644 index 0000000..688e289 --- /dev/null +++ b/backend/grade-service/src/main/java/com/example/gradeservice/exception/UpstreamServiceUnavailableException.java @@ -0,0 +1,8 @@ +package com.example.gradeservice.exception; + +/** Thrown when a call to enrollment-service or course-service fails or times out. */ +public class UpstreamServiceUnavailableException extends RuntimeException { + public UpstreamServiceUnavailableException(String message) { + super(message); + } +} diff --git a/backend/grade-service/src/main/java/com/example/gradeservice/repository/GradeRepository.java b/backend/grade-service/src/main/java/com/example/gradeservice/repository/GradeRepository.java new file mode 100644 index 0000000..62fa2c7 --- /dev/null +++ b/backend/grade-service/src/main/java/com/example/gradeservice/repository/GradeRepository.java @@ -0,0 +1,22 @@ +package com.example.gradeservice.repository; + +import com.example.gradeservice.entity.Grade; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface GradeRepository extends JpaRepository { + + List findByStudentId(Long studentId); + + List findByStudentIdAndSemester(Long studentId, String semester); + + Optional findByStudentIdAndCourseId(Long studentId, Long courseId); + + Optional findByEnrollmentId(Long enrollmentId); + + boolean existsByStudentIdAndCourseId(Long studentId, Long courseId); +} diff --git a/backend/grade-service/src/main/java/com/example/gradeservice/security/JwtAuthenticationFilter.java b/backend/grade-service/src/main/java/com/example/gradeservice/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..8e05ce0 --- /dev/null +++ b/backend/grade-service/src/main/java/com/example/gradeservice/security/JwtAuthenticationFilter.java @@ -0,0 +1,49 @@ +package com.example.gradeservice.security; + +import com.example.common.security.JwtPrincipal; +import com.example.common.security.JwtTokenValidator; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.lang.NonNull; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtTokenValidator jwtTokenValidator; + + @Override + protected void doFilterInternal(@NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain) throws ServletException, IOException { + + String authHeader = request.getHeader("Authorization"); + + if (authHeader != null && authHeader.startsWith("Bearer ")) { + String token = authHeader.substring(7); + + if (jwtTokenValidator.isValid(token) && SecurityContextHolder.getContext().getAuthentication() == null) { + JwtPrincipal principal = jwtTokenValidator.extractPrincipal(token); + List authorities = List.of(new SimpleGrantedAuthority("ROLE_" + principal.getRole())); + + UsernamePasswordAuthenticationToken authToken = + new UsernamePasswordAuthenticationToken(principal, null, authorities); + SecurityContextHolder.getContext().setAuthentication(authToken); + } + } + + filterChain.doFilter(request, response); + } +} diff --git a/backend/grade-service/src/main/java/com/example/gradeservice/service/GradeService.java b/backend/grade-service/src/main/java/com/example/gradeservice/service/GradeService.java new file mode 100644 index 0000000..fbe1c9e --- /dev/null +++ b/backend/grade-service/src/main/java/com/example/gradeservice/service/GradeService.java @@ -0,0 +1,144 @@ +package com.example.gradeservice.service; + +import com.example.common.security.JwtPrincipal; +import com.example.common.security.SecurityUtils; +import com.example.gradeservice.client.CourseClient; +import com.example.gradeservice.client.EnrollmentClient; +import com.example.gradeservice.dto.*; +import com.example.gradeservice.entity.Grade; +import com.example.gradeservice.exception.InvalidEnrollmentException; +import com.example.gradeservice.exception.ResourceNotFoundException; +import com.example.gradeservice.repository.GradeRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Access rules: + * - Only ADMIN can assign a grade (enforced via @PreAuthorize on the + * controller). + * - A STUDENT can only view their OWN grades and GPA; an ADMIN can view + * anyone's. + * + * Grades are assigned against a specific enrollmentId rather than a raw + * (studentId, courseId) pair, so a grade can never exist without a real, + * verified enrollment behind it. credits/semester are pulled from + * course-service and snapshotted onto the Grade row at assignment time. + */ +@Service +@RequiredArgsConstructor +@Transactional +public class GradeService { + + private static final Set GRADABLE_STATUSES = Set.of("CONFIRMED", "COMPLETED"); + + private final GradeRepository gradeRepository; + private final EnrollmentClient enrollmentClient; + private final CourseClient courseClient; + + public GradeResponseDTO assignGrade(GradeAssignRequestDTO request, String bearerToken) { + EnrollmentDTO enrollment = enrollmentClient.getEnrollmentById(request.getEnrollmentId(), bearerToken) + .orElseThrow(() -> new ResourceNotFoundException("Enrollment not found with id: " + request.getEnrollmentId())); + + if (!GRADABLE_STATUSES.contains(enrollment.getStatus())) { + throw new InvalidEnrollmentException( + "Enrollment " + enrollment.getId() + " is " + enrollment.getStatus() + + " and cannot be graded (must be CONFIRMED or COMPLETED)"); + } + + CourseDTO course = courseClient.getCourseById(enrollment.getCourseId(), bearerToken) + .orElseThrow(() -> new ResourceNotFoundException("Course not found with id: " + enrollment.getCourseId())); + + // Upsert: re-assigning a grade for the same student+course updates the existing row. + Grade grade = gradeRepository.findByStudentIdAndCourseId(enrollment.getStudentId(), enrollment.getCourseId()) + .map(existing -> { + existing.setGradePoints(request.getGradePoints()); + existing.setCredits(course.getCredits()); + existing.setSemester(course.getSemester()); + existing.setEnrollmentId(enrollment.getId()); + return existing; + }) + .orElseGet(() -> Grade.builder() + .studentId(enrollment.getStudentId()) + .courseId(enrollment.getCourseId()) + .enrollmentId(enrollment.getId()) + .semester(course.getSemester()) + .credits(course.getCredits()) + .gradePoints(request.getGradePoints()) + .build()); + + Grade saved = gradeRepository.save(grade); + return GradeResponseDTO.fromEntity(saved, course); + } + + @Transactional(readOnly = true) + public List getGradesByStudent(Long studentId, String bearerToken) { + requireAdminOrOwner(studentId); + return gradeRepository.findByStudentId(studentId).stream() + .map(grade -> { + CourseDTO course = courseClient.getCourseById(grade.getCourseId(), bearerToken).orElse(null); + return GradeResponseDTO.fromEntity(grade, course); + }) + .toList(); + } + + @Transactional(readOnly = true) + public GpaResponseDTO getGpa(Long studentId) { + requireAdminOrOwner(studentId); + List grades = gradeRepository.findByStudentId(studentId); + + Map gpaBySemester = new LinkedHashMap<>(); + Map semesterWeightedSum = new LinkedHashMap<>(); + Map semesterCredits = new LinkedHashMap<>(); + + double overallWeightedSum = 0.0; + int overallCredits = 0; + + for (Grade grade : grades) { + String semester = grade.getSemester() != null ? grade.getSemester() : "UNSPECIFIED"; + int credits = grade.getCredits() != null ? grade.getCredits() : 0; + double points = grade.getGradePoints() != null ? grade.getGradePoints() : 0.0; + + overallWeightedSum += points * credits; + overallCredits += credits; + + semesterWeightedSum.merge(semester, points * credits, Double::sum); + semesterCredits.merge(semester, credits, Integer::sum); + } + + for (String semester : semesterWeightedSum.keySet()) { + int credits = semesterCredits.get(semester); + double weighted = semesterWeightedSum.get(semester); + gpaBySemester.put(semester, credits > 0 ? round(weighted / credits) : 0.0); + } + + double cgpa = overallCredits > 0 ? round(overallWeightedSum / overallCredits) : 0.0; + + return GpaResponseDTO.builder() + .studentId(studentId) + .cgpa(cgpa) + .totalCredits(overallCredits) + .gpaBySemester(gpaBySemester) + .build(); + } + + private double round(double value) { + return Math.round(value * 100.0) / 100.0; + } + + private void requireAdminOrOwner(Long studentId) { + JwtPrincipal principal = SecurityUtils.currentUser(); + if (principal == null) { + throw new AccessDeniedException("Authentication required"); + } + if (!principal.isAdmin() && !principal.ownsStudentId(studentId)) { + throw new AccessDeniedException("You may only access your own grades"); + } + } +} diff --git a/backend/grade-service/src/main/resources/application.properties b/backend/grade-service/src/main/resources/application.properties new file mode 100644 index 0000000..ff46f3f --- /dev/null +++ b/backend/grade-service/src/main/resources/application.properties @@ -0,0 +1,31 @@ +spring.application.name=grade-service +server.port=8084 + +# ---- MySQL Datasource ---- +spring.datasource.url=jdbc:mysql://${DB_HOST:localhost}:${DB_PORT:3306}/${DB_NAME:grade_db}?createDatabaseIfNotExist=true&useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC +spring.datasource.username=${DB_USER:root} +spring.datasource.password=${DB_PASSWORD:root} +spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver + +# ---- JPA / Hibernate ---- +spring.jpa.hibernate.ddl-auto=update +spring.jpa.show-sql=true +spring.jpa.properties.hibernate.format_sql=true + +# ---- JWT (must match auth-service's jwt.secret) ---- +jwt.secret=${JWT_SECRET:MzM0NmM4NDNhOWM5NDcyM2FhZmY3NmY2ZGRhYTQ5ZjM0Njc4OTBhYmNkZWYxMjM0NTY3ODkwYWJjZGVm} + +# ---- Inter-service communication ---- +course-service.base-url=${COURSE_SERVICE_URL:http://localhost:8083} +student-service.base-url=${STUDENT_SERVICE_URL:http://localhost:8081} +enrollment-service.base-url=${ENROLLMENT_SERVICE_URL:http://localhost:8082} + +# ---- Swagger ---- +springdoc.api-docs.path=/v3/api-docs +springdoc.swagger-ui.path=/swagger-ui.html + +# ---- Actuator ---- +management.endpoints.web.exposure.include=health,info,metrics + +# ---- Logging ---- +logging.level.com.example.gradeservice=DEBUG diff --git a/backend/student-service/src/main/java/com/example/studentservice/config/JwtConfig.java b/backend/student-service/src/main/java/com/example/studentservice/config/JwtConfig.java new file mode 100644 index 0000000..0eccb64 --- /dev/null +++ b/backend/student-service/src/main/java/com/example/studentservice/config/JwtConfig.java @@ -0,0 +1,27 @@ +package com.example.studentservice.config; + +import com.example.common.security.JwtTokenValidator; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Kept separate from SecurityConfig on purpose: SecurityConfig depends on + * JwtAuthenticationFilter (constructor injection), and JwtAuthenticationFilter + * depends on JwtTokenValidator. If JwtTokenValidator were a @Bean method + * inside SecurityConfig itself, Spring would need SecurityConfig fully + * constructed to produce JwtTokenValidator, but SecurityConfig can't be + * constructed until JwtAuthenticationFilter (and therefore JwtTokenValidator) + * already exists - a circular dependency. Defining it here breaks the cycle. + */ +@Configuration +public class JwtConfig { + + @Value("${jwt.secret}") + private String jwtSecret; + + @Bean + public JwtTokenValidator jwtTokenValidator() { + return new JwtTokenValidator(jwtSecret); + } +} diff --git a/backend/student-service/src/main/java/com/example/studentservice/config/SecurityConfig.java b/backend/student-service/src/main/java/com/example/studentservice/config/SecurityConfig.java index 1055485..fcc1381 100644 --- a/backend/student-service/src/main/java/com/example/studentservice/config/SecurityConfig.java +++ b/backend/student-service/src/main/java/com/example/studentservice/config/SecurityConfig.java @@ -1,9 +1,7 @@ package com.example.studentservice.config; import com.example.studentservice.security.JwtAuthenticationFilter; -import com.example.common.security.JwtTokenValidator; import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; @@ -24,28 +22,10 @@ @RequiredArgsConstructor public class SecurityConfig { - - - @Value("${jwt.secret}") - private String jwtSecret; - - @Bean - public JwtAuthenticationFilter jwtAuthenticationFilter( - JwtTokenValidator jwtTokenValidator) { - - return new JwtAuthenticationFilter(jwtTokenValidator); - } + private final JwtAuthenticationFilter jwtAuthenticationFilter; @Bean - public JwtTokenValidator jwtTokenValidator() { - return new JwtTokenValidator(jwtSecret); - } - - @Bean - public SecurityFilterChain securityFilterChain( - HttpSecurity http, - JwtAuthenticationFilter jwtAuthenticationFilter) throws Exception { - + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .csrf(csrf -> csrf.disable()) .cors(cors -> cors.configurationSource(corsConfigurationSource())) @@ -54,8 +34,7 @@ public SecurityFilterChain securityFilterChain( .requestMatchers("/actuator/health").permitAll() .anyRequest().authenticated() ) - .addFilterBefore(jwtAuthenticationFilter, - UsernamePasswordAuthenticationFilter.class); + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } diff --git a/backend/student-service/src/main/java/com/example/studentservice/security/JwtAuthenticationFilter.java b/backend/student-service/src/main/java/com/example/studentservice/security/JwtAuthenticationFilter.java index ca0d83c..2b0b0c5 100644 --- a/backend/student-service/src/main/java/com/example/studentservice/security/JwtAuthenticationFilter.java +++ b/backend/student-service/src/main/java/com/example/studentservice/security/JwtAuthenticationFilter.java @@ -26,7 +26,7 @@ * JWT signature locally using the shared secret, which is the whole * point of using signed, stateless access tokens between services. */ - +@Component @RequiredArgsConstructor public class JwtAuthenticationFilter extends OncePerRequestFilter { From 273b661fbaf1e2c4c91b3232f1bd1890171e72d9 Mon Sep 17 00:00:00 2001 From: Shaik Mohammed Ashraf Ali Date: Thu, 30 Jul 2026 16:50:21 +0530 Subject: [PATCH 2/2] Run CI on all branches --- .github/workflows/ci.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5afd98c..f8cbbd9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,6 @@ name: CI on: push: - branches: - - main - - phase2 pull_request: branches: