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:
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 {