From 1cb8b698a0c3387d0993a4333dd29be8fb0fcb73 Mon Sep 17 00:00:00 2001 From: Mouad Hallaffou Date: Fri, 20 Mar 2026 13:57:56 +0000 Subject: [PATCH 1/4] feat(academic-service):add attendance entities with dto and mapper and repository --- .../dto/create/AttendanceCreateDto.java | 28 +++++++++++ .../dto/create/AttendanceRecordCreateDto.java | 18 +++++++ .../response/AttendanceRecordResponseDto.java | 11 +++++ .../dto/response/AttendanceResponseDto.java | 21 ++++++++ .../dto/update/AttendanceUpdateDto.java | 24 ++++++++++ .../mapper/AttendanceMapper.java | 48 +++++++++++++++++++ .../academicsService/model/Attendance.java | 34 +++++++++++++ .../model/AttendanceRecord.java | 28 +++++++++++ .../model/enums/AttendanceStatus.java | 8 ++++ .../AttendanceRecordRepository.java | 32 +++++++++++++ .../repository/AttendanceRepository.java | 27 +++++++++++ 11 files changed, 279 insertions(+) create mode 100644 services/academic-service/src/main/java/com/academicsService/dto/create/AttendanceCreateDto.java create mode 100644 services/academic-service/src/main/java/com/academicsService/dto/create/AttendanceRecordCreateDto.java create mode 100644 services/academic-service/src/main/java/com/academicsService/dto/response/AttendanceRecordResponseDto.java create mode 100644 services/academic-service/src/main/java/com/academicsService/dto/response/AttendanceResponseDto.java create mode 100644 services/academic-service/src/main/java/com/academicsService/dto/update/AttendanceUpdateDto.java create mode 100644 services/academic-service/src/main/java/com/academicsService/mapper/AttendanceMapper.java create mode 100644 services/academic-service/src/main/java/com/academicsService/model/Attendance.java create mode 100644 services/academic-service/src/main/java/com/academicsService/model/AttendanceRecord.java create mode 100644 services/academic-service/src/main/java/com/academicsService/model/enums/AttendanceStatus.java create mode 100644 services/academic-service/src/main/java/com/academicsService/repository/AttendanceRecordRepository.java create mode 100644 services/academic-service/src/main/java/com/academicsService/repository/AttendanceRepository.java diff --git a/services/academic-service/src/main/java/com/academicsService/dto/create/AttendanceCreateDto.java b/services/academic-service/src/main/java/com/academicsService/dto/create/AttendanceCreateDto.java new file mode 100644 index 0000000..db309b8 --- /dev/null +++ b/services/academic-service/src/main/java/com/academicsService/dto/create/AttendanceCreateDto.java @@ -0,0 +1,28 @@ +package com.academicsService.dto.create; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.time.LocalDate; +import java.util.List; + +@Data +public class AttendanceCreateDto { + + @NotBlank(message = "Classroom ID is required") + private String classroomId; + + private String subjectId; + + @NotNull(message = "Date is required") + private LocalDate date; + + private String notes; + + @NotEmpty(message = "At least one record is required") + @Valid + private List records; +} diff --git a/services/academic-service/src/main/java/com/academicsService/dto/create/AttendanceRecordCreateDto.java b/services/academic-service/src/main/java/com/academicsService/dto/create/AttendanceRecordCreateDto.java new file mode 100644 index 0000000..9b237f8 --- /dev/null +++ b/services/academic-service/src/main/java/com/academicsService/dto/create/AttendanceRecordCreateDto.java @@ -0,0 +1,18 @@ +package com.academicsService.dto.create; + +import com.academicsService.model.enums.AttendanceStatus; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Data +public class AttendanceRecordCreateDto { + + @NotBlank(message = "Student ID is required") + private String studentId; + + @NotNull(message = "Status is required") + private AttendanceStatus status; + + private String reason; +} diff --git a/services/academic-service/src/main/java/com/academicsService/dto/response/AttendanceRecordResponseDto.java b/services/academic-service/src/main/java/com/academicsService/dto/response/AttendanceRecordResponseDto.java new file mode 100644 index 0000000..a59c951 --- /dev/null +++ b/services/academic-service/src/main/java/com/academicsService/dto/response/AttendanceRecordResponseDto.java @@ -0,0 +1,11 @@ +package com.academicsService.dto.response; + +import com.academicsService.model.enums.AttendanceStatus; + +public record AttendanceRecordResponseDto( + String id, + String attendanceId, + String studentId, + AttendanceStatus status, + String reason +) {} diff --git a/services/academic-service/src/main/java/com/academicsService/dto/response/AttendanceResponseDto.java b/services/academic-service/src/main/java/com/academicsService/dto/response/AttendanceResponseDto.java new file mode 100644 index 0000000..0cb703f --- /dev/null +++ b/services/academic-service/src/main/java/com/academicsService/dto/response/AttendanceResponseDto.java @@ -0,0 +1,21 @@ +package com.academicsService.dto.response; + +import java.time.LocalDate; +import java.util.List; + +public record AttendanceResponseDto( + String id, + String schoolId, + String classroomId, + String subjectId, + String teacherId, + LocalDate date, + String notes, + List records, + long presentCount, + long absentCount, + long lateCount, + long excusedCount, + String createdAt, + String updatedAt +) {} diff --git a/services/academic-service/src/main/java/com/academicsService/dto/update/AttendanceUpdateDto.java b/services/academic-service/src/main/java/com/academicsService/dto/update/AttendanceUpdateDto.java new file mode 100644 index 0000000..6d8d191 --- /dev/null +++ b/services/academic-service/src/main/java/com/academicsService/dto/update/AttendanceUpdateDto.java @@ -0,0 +1,24 @@ +package com.academicsService.dto.update; + +import com.academicsService.model.enums.AttendanceStatus; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.Map; + +@Data +public class AttendanceUpdateDto { + + private String notes; + + // Map of studentId -> { status, reason } + private Map records; + + @Data + public static class RecordUpdate { + @NotNull(message = "Status is required") + private AttendanceStatus status; + private String reason; + } +} diff --git a/services/academic-service/src/main/java/com/academicsService/mapper/AttendanceMapper.java b/services/academic-service/src/main/java/com/academicsService/mapper/AttendanceMapper.java new file mode 100644 index 0000000..26cdd0b --- /dev/null +++ b/services/academic-service/src/main/java/com/academicsService/mapper/AttendanceMapper.java @@ -0,0 +1,48 @@ +package com.academicsService.mapper; + +import com.academicsService.dto.response.AttendanceRecordResponseDto; +import com.academicsService.dto.response.AttendanceResponseDto; +import com.academicsService.model.Attendance; +import com.academicsService.model.AttendanceRecord; +import com.academicsService.model.enums.AttendanceStatus; + +import java.util.List; + +public class AttendanceMapper { + + public static AttendanceRecordResponseDto toRecordDto(AttendanceRecord r) { + return new AttendanceRecordResponseDto( + r.getId(), + r.getAttendanceId(), + r.getStudentId(), + r.getStatus(), + r.getReason() + ); + } + + public static AttendanceResponseDto toResponseDto(Attendance a, List records) { + long present = records.stream().filter(r -> r.getStatus() == AttendanceStatus.PRESENT).count(); + long absent = records.stream().filter(r -> r.getStatus() == AttendanceStatus.ABSENT).count(); + long late = records.stream().filter(r -> r.getStatus() == AttendanceStatus.LATE).count(); + long excused = records.stream().filter(r -> r.getStatus() == AttendanceStatus.EXCUSED).count(); + + return new AttendanceResponseDto( + a.getId(), + a.getSchoolId(), + a.getClassroomId(), + a.getSubjectId(), + a.getTeacherId(), + a.getDate(), + a.getNotes(), + records.stream().map(AttendanceMapper::toRecordDto).toList(), + present, + absent, + late, + excused, + a.getCreatedAt() != null ? a.getCreatedAt().toString() : null, + a.getUpdatedAt() != null ? a.getUpdatedAt().toString() : null + ); + } + + private AttendanceMapper() {} +} diff --git a/services/academic-service/src/main/java/com/academicsService/model/Attendance.java b/services/academic-service/src/main/java/com/academicsService/model/Attendance.java new file mode 100644 index 0000000..02cb63d --- /dev/null +++ b/services/academic-service/src/main/java/com/academicsService/model/Attendance.java @@ -0,0 +1,34 @@ +package com.academicsService.model; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDate; + +@Entity +@Table(name = "attendances") +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Attendance extends BaseEntity { + + @Column(name = "school_id", nullable = false) + private String schoolId; + + @Column(name = "classroom_id", nullable = false) + private String classroomId; + + @Column(name = "subject_id") + private String subjectId; + + @Column(name = "teacher_id", nullable = false) + private String teacherId; + + @Column(nullable = false) + private LocalDate date; + + @Column(columnDefinition = "TEXT") + private String notes; +} diff --git a/services/academic-service/src/main/java/com/academicsService/model/AttendanceRecord.java b/services/academic-service/src/main/java/com/academicsService/model/AttendanceRecord.java new file mode 100644 index 0000000..bf1be48 --- /dev/null +++ b/services/academic-service/src/main/java/com/academicsService/model/AttendanceRecord.java @@ -0,0 +1,28 @@ +package com.academicsService.model; + +import com.academicsService.model.enums.AttendanceStatus; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "attendance_records") +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AttendanceRecord extends BaseEntity { + + @Column(name = "attendance_id", nullable = false) + private String attendanceId; + + @Column(name = "student_id", nullable = false) + private String studentId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private AttendanceStatus status; + + @Column + private String reason; +} diff --git a/services/academic-service/src/main/java/com/academicsService/model/enums/AttendanceStatus.java b/services/academic-service/src/main/java/com/academicsService/model/enums/AttendanceStatus.java new file mode 100644 index 0000000..b4656dd --- /dev/null +++ b/services/academic-service/src/main/java/com/academicsService/model/enums/AttendanceStatus.java @@ -0,0 +1,8 @@ +package com.academicsService.model.enums; + +public enum AttendanceStatus { + PRESENT, + ABSENT, + LATE, + EXCUSED +} diff --git a/services/academic-service/src/main/java/com/academicsService/repository/AttendanceRecordRepository.java b/services/academic-service/src/main/java/com/academicsService/repository/AttendanceRecordRepository.java new file mode 100644 index 0000000..552c744 --- /dev/null +++ b/services/academic-service/src/main/java/com/academicsService/repository/AttendanceRecordRepository.java @@ -0,0 +1,32 @@ +package com.academicsService.repository; + +import com.academicsService.model.AttendanceRecord; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface AttendanceRecordRepository extends JpaRepository { + + List findByAttendanceId(String attendanceId); + + void deleteByAttendanceId(String attendanceId); + + @Query(""" + SELECT ar FROM AttendanceRecord ar + WHERE ar.studentId = :studentId + AND ar.attendanceId IN ( + SELECT a.id FROM Attendance a WHERE a.schoolId = :schoolId + ) + ORDER BY ar.id DESC + """) + Page findByStudentIdAndSchoolId( + @Param("studentId") String studentId, + @Param("schoolId") String schoolId, + Pageable pageable); +} diff --git a/services/academic-service/src/main/java/com/academicsService/repository/AttendanceRepository.java b/services/academic-service/src/main/java/com/academicsService/repository/AttendanceRepository.java new file mode 100644 index 0000000..022c2a5 --- /dev/null +++ b/services/academic-service/src/main/java/com/academicsService/repository/AttendanceRepository.java @@ -0,0 +1,27 @@ +package com.academicsService.repository; + +import com.academicsService.model.Attendance; +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.time.LocalDate; +import java.util.Optional; + +@Repository +public interface AttendanceRepository extends JpaRepository { + + Page findBySchoolId(String schoolId, Pageable pageable); + + Page findBySchoolIdAndClassroomId(String schoolId, String classroomId, Pageable pageable); + + Page findBySchoolIdAndDate(String schoolId, LocalDate date, Pageable pageable); + + Page findBySchoolIdAndClassroomIdAndDate( + String schoolId, String classroomId, LocalDate date, Pageable pageable); + + boolean existsByClassroomIdAndDate(String classroomId, LocalDate date); + + Optional findByClassroomIdAndDate(String classroomId, LocalDate date); +} From 26543ef5d31e4e7a5a1d83f14f5dee0007c35a76 Mon Sep 17 00:00:00 2001 From: Mouad Hallaffou Date: Fri, 20 Mar 2026 13:59:04 +0000 Subject: [PATCH 2/4] feat(academic-service):impl attendance service and controller endpoints , fix ci pipeline --- .github/workflows/main.yml | 10 +- .../controller/AttendanceController.java | 96 +++++++++ .../service/AttendanceService.java | 26 +++ .../service/impl/AttendanceServiceImpl.java | 195 ++++++++++++++++++ 4 files changed, 323 insertions(+), 4 deletions(-) create mode 100644 services/academic-service/src/main/java/com/academicsService/controller/AttendanceController.java create mode 100644 services/academic-service/src/main/java/com/academicsService/service/AttendanceService.java create mode 100644 services/academic-service/src/main/java/com/academicsService/service/impl/AttendanceServiceImpl.java diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 9c59f89..43ee9f0 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -2,9 +2,11 @@ name: CI/CD Pipeline on: push: - branches: [ main, dev, "feature/**" ] + branches: [ main ] + # branches: [ main, dev, "feature/**" ] pull_request: - branches: [ main, dev ] + branches: [ main ] + # branches: [ main, dev ] permissions: contents: read @@ -78,12 +80,12 @@ jobs: docker-build: name: Docker Build & Push runs-on: ubuntu-latest - needs: [build-and-test, qodana] + needs: [ build-and-test, qodana ] if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' strategy: matrix: - service: [config-service, eureka-service, api-gateway, user-service, academic-service] + service: [ config-service, eureka-service, api-gateway, user-service, academic-service ] steps: - name: Checkout code diff --git a/services/academic-service/src/main/java/com/academicsService/controller/AttendanceController.java b/services/academic-service/src/main/java/com/academicsService/controller/AttendanceController.java new file mode 100644 index 0000000..93ad139 --- /dev/null +++ b/services/academic-service/src/main/java/com/academicsService/controller/AttendanceController.java @@ -0,0 +1,96 @@ +package com.academicsService.controller; + +import com.academicsService.dto.create.AttendanceCreateDto; +import com.academicsService.dto.response.AttendanceResponseDto; +import com.academicsService.dto.update.AttendanceUpdateDto; +import com.academicsService.service.AttendanceService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.format.annotation.DateTimeFormat; +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.time.LocalDate; + +@RestController +@RequestMapping("/api/v1/attendances") +@RequiredArgsConstructor +@Slf4j +public class AttendanceController { + + private final AttendanceService attendanceService; + + @PostMapping + @PreAuthorize("hasAnyRole('SCHOOL_ADMIN', 'STAFF', 'TEACHER')") + public ResponseEntity create( + @Valid @RequestBody AttendanceCreateDto dto, + @RequestHeader("X-User-Id") String currentUserId) { + log.info("POST /api/v1/attendances by user: {}", currentUserId); + return ResponseEntity.status(HttpStatus.CREATED) + .body(attendanceService.create(dto, currentUserId)); + } + + @PutMapping("/{id}") + @PreAuthorize("hasAnyRole('SCHOOL_ADMIN', 'STAFF', 'TEACHER')") + public ResponseEntity update( + @PathVariable("id") String id, + @Valid @RequestBody AttendanceUpdateDto dto, + @RequestHeader("X-User-Id") String currentUserId) { + log.info("PUT /api/v1/attendances/{} by user: {}", id, currentUserId); + return ResponseEntity.ok(attendanceService.update(id, dto, currentUserId)); + } + + @GetMapping("/{id}") + @PreAuthorize("hasAnyRole('SCHOOL_ADMIN', 'STAFF', 'TEACHER', 'STUDENT', 'PARENT')") + public ResponseEntity findById( + @PathVariable("id") String id) { + log.info("GET /api/v1/attendances/{}", id); + return ResponseEntity.ok(attendanceService.findById(id)); + } + + @GetMapping + @PreAuthorize("hasAnyRole('SCHOOL_ADMIN', 'STAFF', 'TEACHER')") + public ResponseEntity> findBySchool( + @RequestParam("schoolId") String schoolId, + @RequestParam(value = "classroomId", required = false) String classroomId, + @RequestParam(value = "date", required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + log.info("GET /api/v1/attendances?schoolId={}&classroomId={}&date={}", schoolId, classroomId, date); + return ResponseEntity.ok(attendanceService.findBySchool( + schoolId, classroomId, date, + PageRequest.of(page, size, Sort.by("date").descending()) + )); + } + + @GetMapping("/student/{studentId}") + @PreAuthorize("hasAnyRole('SCHOOL_ADMIN', 'STAFF', 'TEACHER', 'STUDENT', 'PARENT')") + public ResponseEntity> findByStudent( + @PathVariable("studentId") String studentId, + @RequestParam("schoolId") String schoolId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + log.info("GET /api/v1/attendances/student/{}", studentId); + return ResponseEntity.ok(attendanceService.findByStudent( + studentId, schoolId, + PageRequest.of(page, size) + )); + } + + @DeleteMapping("/{id}") + @PreAuthorize("hasAnyRole('SCHOOL_ADMIN', 'STAFF')") + public ResponseEntity delete( + @PathVariable("id") String id, + @RequestHeader("X-User-Id") String currentUserId) { + log.info("DELETE /api/v1/attendances/{} by user: {}", id, currentUserId); + attendanceService.delete(id, currentUserId); + return ResponseEntity.noContent().build(); + } +} diff --git a/services/academic-service/src/main/java/com/academicsService/service/AttendanceService.java b/services/academic-service/src/main/java/com/academicsService/service/AttendanceService.java new file mode 100644 index 0000000..2a843c3 --- /dev/null +++ b/services/academic-service/src/main/java/com/academicsService/service/AttendanceService.java @@ -0,0 +1,26 @@ +package com.academicsService.service; + +import com.academicsService.dto.create.AttendanceCreateDto; +import com.academicsService.dto.response.AttendanceResponseDto; +import com.academicsService.dto.update.AttendanceUpdateDto; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.time.LocalDate; + +public interface AttendanceService { + + AttendanceResponseDto create(AttendanceCreateDto dto, String currentUserId); + + AttendanceResponseDto update(String id, AttendanceUpdateDto dto, String currentUserId); + + AttendanceResponseDto findById(String id); + + Page findBySchool( + String schoolId, String classroomId, LocalDate date, Pageable pageable); + + Page findByStudent( + String studentId, String schoolId, Pageable pageable); + + void delete(String id, String currentUserId); +} diff --git a/services/academic-service/src/main/java/com/academicsService/service/impl/AttendanceServiceImpl.java b/services/academic-service/src/main/java/com/academicsService/service/impl/AttendanceServiceImpl.java new file mode 100644 index 0000000..15cac7c --- /dev/null +++ b/services/academic-service/src/main/java/com/academicsService/service/impl/AttendanceServiceImpl.java @@ -0,0 +1,195 @@ +package com.academicsService.service.impl; + +import com.academicsService.client.SchoolValidationService; +import com.academicsService.dto.create.AttendanceCreateDto; +import com.academicsService.dto.create.AttendanceRecordCreateDto; +import com.academicsService.dto.response.AttendanceResponseDto; +import com.academicsService.dto.update.AttendanceUpdateDto; +import com.academicsService.exception.BusinessRuleException; +import com.academicsService.exception.ResourceNotFoundException; +import com.academicsService.mapper.AttendanceMapper; +import com.academicsService.model.Attendance; +import com.academicsService.model.AttendanceRecord; +import com.academicsService.repository.AttendanceRecordRepository; +import com.academicsService.repository.AttendanceRepository; +import com.academicsService.service.AttendanceService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +@Service +@RequiredArgsConstructor +@Slf4j +public class AttendanceServiceImpl implements AttendanceService { + + private final AttendanceRepository attendanceRepository; + private final AttendanceRecordRepository recordRepository; + private final SchoolValidationService schoolValidationService; + + @Override + @Transactional + public AttendanceResponseDto create(AttendanceCreateDto dto, String currentUserId) { + log.info("Creating attendance for classroom '{}' on '{}' by user '{}'", + dto.getClassroomId(), dto.getDate(), currentUserId); + + String schoolId = schoolValidationService.getSchoolIdByUserId(currentUserId); + + // Prevent duplicate session for same classroom + date + if (attendanceRepository.existsByClassroomIdAndDate(dto.getClassroomId(), dto.getDate())) { + throw new BusinessRuleException( + "An attendance session already exists for this classroom on " + dto.getDate() + + ". Use update instead." + ); + } + + Attendance attendance = Attendance.builder() + .schoolId(schoolId) + .classroomId(dto.getClassroomId()) + .subjectId(dto.getSubjectId()) + .teacherId(currentUserId) + .date(dto.getDate()) + .notes(dto.getNotes()) + .build(); + + Attendance saved = attendanceRepository.save(attendance); + + List records = new ArrayList<>(); + for (AttendanceRecordCreateDto r : dto.getRecords()) { + AttendanceRecord record = AttendanceRecord.builder() + .attendanceId(saved.getId()) + .studentId(r.getStudentId()) + .status(r.getStatus()) + .reason(r.getReason()) + .build(); + records.add(recordRepository.save(record)); + } + + log.info("Attendance '{}' created with {} records", saved.getId(), records.size()); + return AttendanceMapper.toResponseDto(saved, records); + } + + @Override + @Transactional + public AttendanceResponseDto update(String id, AttendanceUpdateDto dto, String currentUserId) { + log.info("Updating attendance '{}' by user '{}'", id, currentUserId); + + Attendance attendance = findEntityById(id); + String schoolId = schoolValidationService.getSchoolIdByUserId(currentUserId); + + if (!attendance.getSchoolId().equals(schoolId)) { + throw new BusinessRuleException("You don't have permission to update this attendance session"); + } + + if (dto.getNotes() != null) { + attendance.setNotes(dto.getNotes()); + } + attendanceRepository.save(attendance); + + // Update individual records + List records = recordRepository.findByAttendanceId(id); + if (dto.getRecords() != null) { + for (AttendanceRecord record : records) { + AttendanceUpdateDto.RecordUpdate update = dto.getRecords().get(record.getStudentId()); + if (update != null) { + record.setStatus(update.getStatus()); + record.setReason(update.getReason()); + recordRepository.save(record); + } + } + // Re-fetch after updates + records = recordRepository.findByAttendanceId(id); + } + + log.info("Attendance '{}' updated", id); + return AttendanceMapper.toResponseDto(attendance, records); + } + + @Override + @Transactional(readOnly = true) + public AttendanceResponseDto findById(String id) { + Attendance attendance = findEntityById(id); + List records = recordRepository.findByAttendanceId(id); + return AttendanceMapper.toResponseDto(attendance, records); + } + + @Override + @Transactional(readOnly = true) + public Page findBySchool( + String schoolId, String classroomId, LocalDate date, Pageable pageable) { + log.debug("Fetching attendances for school '{}' classroom '{}' date '{}'", + schoolId, classroomId, date); + + Page page; + if (classroomId != null && date != null) { + page = attendanceRepository.findBySchoolIdAndClassroomIdAndDate(schoolId, classroomId, date, pageable); + } else if (classroomId != null) { + page = attendanceRepository.findBySchoolIdAndClassroomId(schoolId, classroomId, pageable); + } else if (date != null) { + page = attendanceRepository.findBySchoolIdAndDate(schoolId, date, pageable); + } else { + page = attendanceRepository.findBySchoolId(schoolId, pageable); + } + + return page.map(a -> { + List records = recordRepository.findByAttendanceId(a.getId()); + return AttendanceMapper.toResponseDto(a, records); + }); + } + + @Override + @Transactional(readOnly = true) + public Page findByStudent( + String studentId, String schoolId, Pageable pageable) { + log.debug("Fetching attendance records for student '{}' school '{}'", studentId, schoolId); + + Page recordPage = recordRepository.findByStudentIdAndSchoolId( + studentId, schoolId, pageable); + + List results = new ArrayList<>(); + for (AttendanceRecord r : recordPage.getContent()) { + Attendance attendance = attendanceRepository.findById(r.getAttendanceId()).orElse(null); + if (attendance != null) { + // For student view: only show their own record + results.add(AttendanceMapper.toResponseDto(attendance, List.of(r))); + } + } + + return new PageImpl<>(results, pageable, recordPage.getTotalElements()); + } + + @Override + @Transactional + public void delete(String id, String currentUserId) { + log.info("Deleting attendance '{}' by user '{}'", id, currentUserId); + + Attendance attendance = findEntityById(id); + String schoolId = schoolValidationService.getSchoolIdByUserId(currentUserId); + + if (!attendance.getSchoolId().equals(schoolId)) { + throw new BusinessRuleException("You don't have permission to delete this attendance session"); + } + + recordRepository.deleteByAttendanceId(id); + attendanceRepository.delete(attendance); + log.info("Attendance '{}' and all its records deleted", id); + } + + // ================================================================ + // PRIVATE HELPERS + // ================================================================ + + private Attendance findEntityById(String id) { + return attendanceRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException( + "Attendance session not found with id: " + id)); + } +} From 5de548519e2fe0ad7b75f97e9117df757e42ee25 Mon Sep 17 00:00:00 2001 From: Mouad Hallaffou Date: Mon, 23 Mar 2026 15:58:13 +0100 Subject: [PATCH 3/4] feat(academic-service): add isCurrent property to academic year DTOs and create attendance tables --- .../dto/create/AcademicYearCreateDto.java | 2 ++ .../dto/response/AcademicYearResponseDto.java | 3 +++ .../dto/update/AcademicYearUpdateDto.java | 2 ++ .../db/migration/V5__add_attendance.sql | 26 +++++++++++++++++++ .../config/GatewaySecurityConfig.java | 11 ++++---- .../resources/config-repo/user-service.yml | 2 +- 6 files changed, 39 insertions(+), 7 deletions(-) create mode 100644 services/academic-service/src/main/resources/db/migration/V5__add_attendance.sql diff --git a/services/academic-service/src/main/java/com/academicsService/dto/create/AcademicYearCreateDto.java b/services/academic-service/src/main/java/com/academicsService/dto/create/AcademicYearCreateDto.java index ca052f3..9de5295 100644 --- a/services/academic-service/src/main/java/com/academicsService/dto/create/AcademicYearCreateDto.java +++ b/services/academic-service/src/main/java/com/academicsService/dto/create/AcademicYearCreateDto.java @@ -1,5 +1,6 @@ package com.academicsService.dto.create; +import com.fasterxml.jackson.annotation.JsonProperty; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; @@ -24,6 +25,7 @@ public class AcademicYearCreateDto { @NotNull(message = "End date is required") private LocalDate endDate; + @JsonProperty("isCurrent") private boolean isCurrent; } diff --git a/services/academic-service/src/main/java/com/academicsService/dto/response/AcademicYearResponseDto.java b/services/academic-service/src/main/java/com/academicsService/dto/response/AcademicYearResponseDto.java index ec2d8b7..948a99d 100644 --- a/services/academic-service/src/main/java/com/academicsService/dto/response/AcademicYearResponseDto.java +++ b/services/academic-service/src/main/java/com/academicsService/dto/response/AcademicYearResponseDto.java @@ -1,5 +1,7 @@ package com.academicsService.dto.response; +import com.fasterxml.jackson.annotation.JsonProperty; + import java.time.LocalDate; public record AcademicYearResponseDto( @@ -8,6 +10,7 @@ public record AcademicYearResponseDto( String name, LocalDate startDate, LocalDate endDate, + @JsonProperty("isCurrent") boolean isCurrent ) {} diff --git a/services/academic-service/src/main/java/com/academicsService/dto/update/AcademicYearUpdateDto.java b/services/academic-service/src/main/java/com/academicsService/dto/update/AcademicYearUpdateDto.java index 47766d5..100bfeb 100644 --- a/services/academic-service/src/main/java/com/academicsService/dto/update/AcademicYearUpdateDto.java +++ b/services/academic-service/src/main/java/com/academicsService/dto/update/AcademicYearUpdateDto.java @@ -1,5 +1,6 @@ package com.academicsService.dto.update; +import com.fasterxml.jackson.annotation.JsonProperty; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; @@ -24,6 +25,7 @@ public class AcademicYearUpdateDto { @NotNull(message = "End date is required") private LocalDate endDate; + @JsonProperty("isCurrent") private boolean isCurrent; } diff --git a/services/academic-service/src/main/resources/db/migration/V5__add_attendance.sql b/services/academic-service/src/main/resources/db/migration/V5__add_attendance.sql new file mode 100644 index 0000000..b06bf3f --- /dev/null +++ b/services/academic-service/src/main/resources/db/migration/V5__add_attendance.sql @@ -0,0 +1,26 @@ +CREATE TABLE attendance_records +( + id VARCHAR(255) NOT NULL, + created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL, + updated_at TIMESTAMP WITHOUT TIME ZONE, + attendance_id VARCHAR(255) NOT NULL, + student_id VARCHAR(255) NOT NULL, + status VARCHAR(255) NOT NULL, + reason VARCHAR(255), + CONSTRAINT pk_attendance_records PRIMARY KEY (id) +); + +CREATE TABLE attendances +( + id VARCHAR(255) NOT NULL, + created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL, + updated_at TIMESTAMP WITHOUT TIME ZONE, + school_id VARCHAR(255) NOT NULL, + classroom_id VARCHAR(255) NOT NULL, + subject_id VARCHAR(255), + teacher_id VARCHAR(255) NOT NULL, + date date NOT NULL, + notes TEXT, + CONSTRAINT pk_attendances PRIMARY KEY (id) +); + diff --git a/services/api-gateway/src/main/java/com/apigateway/config/GatewaySecurityConfig.java b/services/api-gateway/src/main/java/com/apigateway/config/GatewaySecurityConfig.java index 0afd52d..fa27288 100644 --- a/services/api-gateway/src/main/java/com/apigateway/config/GatewaySecurityConfig.java +++ b/services/api-gateway/src/main/java/com/apigateway/config/GatewaySecurityConfig.java @@ -32,11 +32,10 @@ public SecurityWebFilterChain springSecurityFilterChain( .authorizeExchange(exchanges -> exchanges .pathMatchers(HttpMethod.OPTIONS, "/**").permitAll() .pathMatchers( - "/api/auth/**", - "/api/admin/super-admin", - "/api/schools/register", - "/api/users/v1/schools/register", - "/actuator/**" + "/api/auth/**", + "/api/admin/super-admin", + "/api/schools/v1/schools/register", + "/actuator/**" ).permitAll() .anyExchange().authenticated() ) @@ -102,4 +101,4 @@ private List extractRoles(Jwt jwt) { new SimpleGrantedAuthority("ROLE_" + role)) .toList(); } -} \ No newline at end of file +} diff --git a/services/config-service/src/main/resources/config-repo/user-service.yml b/services/config-service/src/main/resources/config-repo/user-service.yml index dc57bfc..dced79c 100644 --- a/services/config-service/src/main/resources/config-repo/user-service.yml +++ b/services/config-service/src/main/resources/config-repo/user-service.yml @@ -21,7 +21,7 @@ keycloak: realm: schoolsphere resource: schoolsphere-app credentials: - secret: jfMBILB8hJMjggs8VUHYU4oDoyjVa3qw + secret: erwM0QLaq7iGHYsfwbAV1ZNvRoD6infi admin: username: ${KEYCLOAK_ADMIN_USERNAME:admin} password: ${KEYCLOAK_ADMIN_PASSWORD:admin} From 9583f476c8bfb1c0834939b320336fd51cdb8e4e Mon Sep 17 00:00:00 2001 From: Mouad Hallaffou Date: Wed, 25 Mar 2026 10:51:58 +0100 Subject: [PATCH 4/4] feat(database): add unique constraints for academic years and grade levels --- services/academic-service/pom.xml | 7 +- .../controller/AcademicYearController.java | 2 +- .../StudentClassroomController.java | 2 +- .../controller/TeacherSubjectController.java | 26 ++++--- .../exception/GlobalExceptionHandler.java | 13 ++++ .../academicsService/model/AcademicYear.java | 7 +- .../academicsService/model/GradeLevel.java | 8 +- .../StudentClassroomRepository.java | 25 +++++-- .../repository/TeacherSubjectRepository.java | 75 +++++++++++++++++-- .../service/TeacherSubjectService.java | 4 + .../impl/StudentClassroomServiceImpl.java | 29 +++++-- .../impl/TeacherSubjectServiceImpl.java | 57 +++++++++++--- .../migration/V6__edit_academic_year_name.sql | 2 + .../V7__fix_grade_level_constraints.sql | 24 ++++++ .../V8__fix_academic_year_constraints.sql | 16 ++++ services/api-gateway/pom.xml | 2 +- .../config/GatewaySecurityConfig.java | 4 +- .../resources/config-repo/api-gateway.yml | 2 +- services/user-service/pom.xml | 2 +- 19 files changed, 249 insertions(+), 58 deletions(-) create mode 100644 services/academic-service/src/main/resources/db/migration/V6__edit_academic_year_name.sql create mode 100644 services/academic-service/src/main/resources/db/migration/V7__fix_grade_level_constraints.sql create mode 100644 services/academic-service/src/main/resources/db/migration/V8__fix_academic_year_constraints.sql diff --git a/services/academic-service/pom.xml b/services/academic-service/pom.xml index 49d5d9c..296fca5 100644 --- a/services/academic-service/pom.xml +++ b/services/academic-service/pom.xml @@ -91,11 +91,6 @@ cloudinary-http44 1.36.0 - - - org.springframework.boot - spring-boot-starter-web - @@ -128,4 +123,4 @@ - \ No newline at end of file + diff --git a/services/academic-service/src/main/java/com/academicsService/controller/AcademicYearController.java b/services/academic-service/src/main/java/com/academicsService/controller/AcademicYearController.java index edcb78c..bd588bb 100644 --- a/services/academic-service/src/main/java/com/academicsService/controller/AcademicYearController.java +++ b/services/academic-service/src/main/java/com/academicsService/controller/AcademicYearController.java @@ -83,7 +83,7 @@ public ResponseEntity setAsCurrent( } @DeleteMapping("/{id}") - @PreAuthorize("hasRole('SCHOOL_ADMIN')") + @PreAuthorize("hasAnyRole('SCHOOL_ADMIN', 'STAFF')") public ResponseEntity delete( @PathVariable("id") String id, @RequestHeader("X-User-Id") String currentUserId) { diff --git a/services/academic-service/src/main/java/com/academicsService/controller/StudentClassroomController.java b/services/academic-service/src/main/java/com/academicsService/controller/StudentClassroomController.java index 2b6ec36..bc8a084 100644 --- a/services/academic-service/src/main/java/com/academicsService/controller/StudentClassroomController.java +++ b/services/academic-service/src/main/java/com/academicsService/controller/StudentClassroomController.java @@ -78,7 +78,7 @@ public ResponseEntity> findByStudent( @PreAuthorize("hasAnyRole('SCHOOL_ADMIN', 'TEACHER', 'STAFF', 'PARENT', 'STUDENT')") public ResponseEntity findCurrentByStudent( @PathVariable("studentId") String studentId, - @RequestParam("academicYear") String academicYear) { + @RequestParam(value = "academicYear", required = false) String academicYear) { log.info("GET /api/v1/student-classrooms/student/{}/current", studentId); return ResponseEntity.ok( studentClassroomService.findCurrentByStudent(studentId, academicYear) diff --git a/services/academic-service/src/main/java/com/academicsService/controller/TeacherSubjectController.java b/services/academic-service/src/main/java/com/academicsService/controller/TeacherSubjectController.java index be9e79e..1615201 100644 --- a/services/academic-service/src/main/java/com/academicsService/controller/TeacherSubjectController.java +++ b/services/academic-service/src/main/java/com/academicsService/controller/TeacherSubjectController.java @@ -57,21 +57,22 @@ public ResponseEntity findById( @PreAuthorize("hasAnyRole('SCHOOL_ADMIN', 'TEACHER', 'STAFF')") public ResponseEntity> findByTeacher( @PathVariable("teacherId") String teacherId, - @RequestParam("academicYear") String academicYear, + @RequestParam(value = "academicYear", required = false) String academicYear, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "10") int size) { log.info("GET /api/v1/teacher-subjects/teacher/{}", teacherId); - return ResponseEntity.ok(teacherSubjectService.findByTeacherAndYear( - teacherId, academicYear, - PageRequest.of(page, size, Sort.by("academicYear").descending()) - )); + PageRequest pageable = PageRequest.of(page, size, Sort.by("academicYear").descending()); + if (academicYear != null && !academicYear.isBlank()) { + return ResponseEntity.ok(teacherSubjectService.findByTeacherAndYear(teacherId, academicYear, pageable)); + } + return ResponseEntity.ok(teacherSubjectService.findByTeacher(teacherId, pageable)); } @GetMapping("/subject/{subjectId}") @PreAuthorize("hasAnyRole('SCHOOL_ADMIN', 'STAFF')") public ResponseEntity> findBySubject( @PathVariable("subjectId") String subjectId, - @RequestParam("academicYear") String academicYear, + @RequestParam(value = "academicYear", required = false) String academicYear, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "10") int size) { log.info("GET /api/v1/teacher-subjects/subject/{}", subjectId); @@ -82,18 +83,19 @@ public ResponseEntity> findBySubject( } @GetMapping("/school") - @PreAuthorize("hasAnyRole('SCHOOL_ADMIN', 'STAFF')") + @PreAuthorize("hasAnyRole('SCHOOL_ADMIN', 'STAFF', 'TEACHER', 'STUDENT', 'PARENT')") public ResponseEntity> findBySchool( @RequestHeader("X-User-Id") String currentUserId, - @RequestParam("academicYear") String academicYear, + @RequestParam(value = "academicYear", required = false) String academicYear, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "10") int size) { String schoolId = schoolValidationService.getSchoolIdByUserId(currentUserId); log.info("GET /api/v1/teacher-subjects/school schoolId={} academicYear={}", schoolId, academicYear); - return ResponseEntity.ok(teacherSubjectService.findBySchoolAndYear( - schoolId, academicYear, - PageRequest.of(page, size, Sort.by("academicYear").descending()) - )); + PageRequest pageable = PageRequest.of(page, size, Sort.by("academicYear").descending()); + if (academicYear != null && !academicYear.isBlank()) { + return ResponseEntity.ok(teacherSubjectService.findBySchoolAndYear(schoolId, academicYear, pageable)); + } + return ResponseEntity.ok(teacherSubjectService.findBySchool(schoolId, pageable)); } @DeleteMapping("/{id}") diff --git a/services/academic-service/src/main/java/com/academicsService/exception/GlobalExceptionHandler.java b/services/academic-service/src/main/java/com/academicsService/exception/GlobalExceptionHandler.java index 7f40640..651012b 100644 --- a/services/academic-service/src/main/java/com/academicsService/exception/GlobalExceptionHandler.java +++ b/services/academic-service/src/main/java/com/academicsService/exception/GlobalExceptionHandler.java @@ -10,6 +10,7 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import java.time.LocalDateTime; @@ -118,6 +119,18 @@ public ResponseEntity handleTypeMismatchException( ); } + @ExceptionHandler(MissingServletRequestParameterException.class) + public ResponseEntity handleMissingParams( + MissingServletRequestParameterException ex, + HttpServletRequest request) { + log.warn("Missing parameter: {}", ex.getParameterName()); + return buildErrorResponse( + HttpStatus.BAD_REQUEST, + "Required parameter '" + ex.getParameterName() + "' is missing", + request.getRequestURI() + ); + } + @ExceptionHandler(FileUploadException.class) public ResponseEntity handleFileUploadException( FileUploadException ex, diff --git a/services/academic-service/src/main/java/com/academicsService/model/AcademicYear.java b/services/academic-service/src/main/java/com/academicsService/model/AcademicYear.java index c810bd4..d8e5cdd 100644 --- a/services/academic-service/src/main/java/com/academicsService/model/AcademicYear.java +++ b/services/academic-service/src/main/java/com/academicsService/model/AcademicYear.java @@ -3,6 +3,7 @@ import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; import lombok.AllArgsConstructor; import lombok.Builder; @@ -13,7 +14,9 @@ import java.time.LocalDate; @Entity -@Table(name = "academic_years") +@Table(name = "academic_years", uniqueConstraints = { + @UniqueConstraint(name = "uc_academic_years_school_name", columnNames = {"school_id", "name"}) +}) @Getter @Setter @Builder @@ -21,7 +24,7 @@ @AllArgsConstructor public class AcademicYear extends BaseEntity { - @Column(name = "name", nullable = false, unique = true) + @Column(name = "name", nullable = false) private String name; // "2025 - 2026" @Column(name = "start_date", nullable = false) diff --git a/services/academic-service/src/main/java/com/academicsService/model/GradeLevel.java b/services/academic-service/src/main/java/com/academicsService/model/GradeLevel.java index f698527..981ab13 100644 --- a/services/academic-service/src/main/java/com/academicsService/model/GradeLevel.java +++ b/services/academic-service/src/main/java/com/academicsService/model/GradeLevel.java @@ -6,6 +6,7 @@ import jakarta.persistence.Entity; import jakarta.persistence.OneToMany; import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -16,7 +17,10 @@ import java.util.List; @Entity -@Table(name = "grade_levels") +@Table(name = "grade_levels", uniqueConstraints = { + @UniqueConstraint(name = "uc_grade_levels_school_display_order", columnNames = {"school_id", "display_order"}), + @UniqueConstraint(name = "uc_grade_levels_school_name", columnNames = {"school_id", "name"}) +}) @Getter @Setter @Builder @@ -33,7 +37,7 @@ public class GradeLevel extends BaseEntity { @Column(columnDefinition = "TEXT") private String description; - @Column(name = "display_order", nullable = false, unique = true) + @Column(name = "display_order", nullable = false) private int displayOrder; @OneToMany(mappedBy = "gradeLevel", cascade = CascadeType.ALL, orphanRemoval = true) diff --git a/services/academic-service/src/main/java/com/academicsService/repository/StudentClassroomRepository.java b/services/academic-service/src/main/java/com/academicsService/repository/StudentClassroomRepository.java index f711ba9..20d3a43 100644 --- a/services/academic-service/src/main/java/com/academicsService/repository/StudentClassroomRepository.java +++ b/services/academic-service/src/main/java/com/academicsService/repository/StudentClassroomRepository.java @@ -1,8 +1,6 @@ package com.academicsService.repository; import com.academicsService.model.StudentClassroom; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -14,15 +12,32 @@ @Repository public interface StudentClassroomRepository extends JpaRepository { - Page findByClassroomIdAndIsActiveTrue(String classroomId, Pageable pageable); + @Query(""" + SELECT sc FROM StudentClassroom sc + JOIN FETCH sc.classroom c + JOIN FETCH c.gradeLevel + WHERE c.id = :classroomId + AND sc.isActive = true + """) + List findByClassroomIdAndIsActiveTrueWithGraph( + @Param("classroomId") String classroomId); - List findByStudentIdAndIsActiveTrue(String studentId); + @Query(""" + SELECT sc FROM StudentClassroom sc + JOIN FETCH sc.classroom c + JOIN FETCH c.gradeLevel + WHERE sc.studentId = :studentId + AND sc.isActive = true + """) + List findByStudentIdAndIsActiveTrue(@Param("studentId") String studentId); @Query(""" SELECT sc FROM StudentClassroom sc + JOIN FETCH sc.classroom c + JOIN FETCH c.gradeLevel WHERE sc.studentId = :studentId AND sc.isActive = true - AND sc.classroom.academicYear = :academicYear + AND c.academicYear = :academicYear """) Optional findCurrentByStudentId(@Param("studentId") String studentId, @Param("academicYear") String academicYear); diff --git a/services/academic-service/src/main/java/com/academicsService/repository/TeacherSubjectRepository.java b/services/academic-service/src/main/java/com/academicsService/repository/TeacherSubjectRepository.java index 372b249..da7f6dc 100644 --- a/services/academic-service/src/main/java/com/academicsService/repository/TeacherSubjectRepository.java +++ b/services/academic-service/src/main/java/com/academicsService/repository/TeacherSubjectRepository.java @@ -1,22 +1,81 @@ package com.academicsService.repository; import com.academicsService.model.TeacherSubject; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import java.util.List; +import java.util.Optional; + @Repository public interface TeacherSubjectRepository extends JpaRepository { - Page findByTeacherIdAndAcademicYear( - String teacherId, String academicYear, Pageable pageable); + @Query(""" + SELECT ts FROM TeacherSubject ts + JOIN FETCH ts.subject s + JOIN FETCH s.gradeLevel + WHERE ts.teacherId = :teacherId + AND ts.academicYear = :academicYear + """) + List findByTeacherIdAndAcademicYear( + @Param("teacherId") String teacherId, + @Param("academicYear") String academicYear); + + @Query(""" + SELECT ts FROM TeacherSubject ts + JOIN FETCH ts.subject s + JOIN FETCH s.gradeLevel + WHERE s.id = :subjectId + AND ts.academicYear = :academicYear + """) + List findBySubjectIdAndAcademicYear( + @Param("subjectId") String subjectId, + @Param("academicYear") String academicYear); + + @Query(""" + SELECT ts FROM TeacherSubject ts + JOIN FETCH ts.subject s + JOIN FETCH s.gradeLevel + WHERE ts.schoolId = :schoolId + AND ts.academicYear = :academicYear + """) + List findBySchoolIdAndAcademicYear( + @Param("schoolId") String schoolId, + @Param("academicYear") String academicYear); + + @Query(""" + SELECT ts FROM TeacherSubject ts + JOIN FETCH ts.subject s + JOIN FETCH s.gradeLevel + WHERE ts.schoolId = :schoolId + """) + List findBySchoolId(@Param("schoolId") String schoolId); + + @Query(""" + SELECT ts FROM TeacherSubject ts + JOIN FETCH ts.subject s + JOIN FETCH s.gradeLevel + WHERE ts.teacherId = :teacherId + """) + List findByTeacherId(@Param("teacherId") String teacherId); - Page findBySubjectIdAndAcademicYear( - String subjectId, String academicYear, Pageable pageable); + @Query(""" + SELECT ts FROM TeacherSubject ts + JOIN FETCH ts.subject s + JOIN FETCH s.gradeLevel + WHERE s.id = :subjectId + """) + List findBySubjectId(@Param("subjectId") String subjectId); - Page findBySchoolIdAndAcademicYear( - String schoolId, String academicYear, Pageable pageable); + @Query(""" + SELECT ts FROM TeacherSubject ts + JOIN FETCH ts.subject s + JOIN FETCH s.gradeLevel + WHERE ts.id = :id + """) + Optional findByIdWithGraph(@Param("id") String id); boolean existsByTeacherIdAndSubjectIdAndAcademicYear( String teacherId, String subjectId, String academicYear); diff --git a/services/academic-service/src/main/java/com/academicsService/service/TeacherSubjectService.java b/services/academic-service/src/main/java/com/academicsService/service/TeacherSubjectService.java index f59a65c..c449377 100644 --- a/services/academic-service/src/main/java/com/academicsService/service/TeacherSubjectService.java +++ b/services/academic-service/src/main/java/com/academicsService/service/TeacherSubjectService.java @@ -14,6 +14,8 @@ public interface TeacherSubjectService { TeacherSubjectResponseDto findById(String id); + Page findByTeacher(String teacherId, Pageable pageable); + Page findByTeacherAndYear( String teacherId, String academicYear, Pageable pageable); @@ -23,6 +25,8 @@ Page findBySubjectAndYear( Page findBySchoolAndYear( String schoolId, String academicYear, Pageable pageable); + Page findBySchool(String schoolId, Pageable pageable); + void delete(String id); } diff --git a/services/academic-service/src/main/java/com/academicsService/service/impl/StudentClassroomServiceImpl.java b/services/academic-service/src/main/java/com/academicsService/service/impl/StudentClassroomServiceImpl.java index a34e151..27be998 100644 --- a/services/academic-service/src/main/java/com/academicsService/service/impl/StudentClassroomServiceImpl.java +++ b/services/academic-service/src/main/java/com/academicsService/service/impl/StudentClassroomServiceImpl.java @@ -17,6 +17,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -124,9 +125,12 @@ public Page findByClassroom( String classroomId, Pageable pageable) { log.debug("Fetching enrollments for classroom '{}'", classroomId); findClassroomById(classroomId); - return studentClassroomRepository - .findByClassroomIdAndIsActiveTrue(classroomId, pageable) - .map(StudentClassroomMapper::toResponseDto); + List content = studentClassroomRepository + .findByClassroomIdAndIsActiveTrueWithGraph(classroomId) + .stream() + .map(StudentClassroomMapper::toResponseDto) + .toList(); + return new PageImpl<>(content, pageable, content.size()); } @Override @@ -146,12 +150,25 @@ public StudentClassroomResponseDto findCurrentByStudent( String studentId, String academicYear) { log.debug("Fetching current enrollment for student '{}' year '{}'", studentId, academicYear); + + if (academicYear != null && !academicYear.isBlank()) { + return studentClassroomRepository + .findCurrentByStudentId(studentId, academicYear) + .map(StudentClassroomMapper::toResponseDto) + .orElseThrow(() -> new ResourceNotFoundException( + "No active enrollment found for student: " + studentId + + " in academic year: " + academicYear + )); + } + + // No academicYear provided — return the most recent active enrollment return studentClassroomRepository - .findCurrentByStudentId(studentId, academicYear) + .findByStudentIdAndIsActiveTrue(studentId) + .stream() + .findFirst() .map(StudentClassroomMapper::toResponseDto) .orElseThrow(() -> new ResourceNotFoundException( - "No active enrollment found for student: " + studentId + - " in academic year: " + academicYear + "No active enrollment found for student: " + studentId )); } diff --git a/services/academic-service/src/main/java/com/academicsService/service/impl/TeacherSubjectServiceImpl.java b/services/academic-service/src/main/java/com/academicsService/service/impl/TeacherSubjectServiceImpl.java index cdaec3e..6cbffa4 100644 --- a/services/academic-service/src/main/java/com/academicsService/service/impl/TeacherSubjectServiceImpl.java +++ b/services/academic-service/src/main/java/com/academicsService/service/impl/TeacherSubjectServiceImpl.java @@ -16,10 +16,13 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.List; + @Service @RequiredArgsConstructor @Slf4j @@ -108,14 +111,29 @@ public TeacherSubjectResponseDto findById(String id) { return TeacherSubjectMapper.toResponseDto(findEntityById(id)); } + @Override + @Transactional(readOnly = true) + public Page findByTeacher(String teacherId, Pageable pageable) { + log.debug("Fetching all subjects for teacher '{}'", teacherId); + List content = teacherSubjectRepository + .findByTeacherId(teacherId) + .stream() + .map(TeacherSubjectMapper::toResponseDto) + .toList(); + return new PageImpl<>(content, pageable, content.size()); + } + @Override @Transactional(readOnly = true) public Page findByTeacherAndYear( String teacherId, String academicYear, Pageable pageable) { log.debug("Fetching subjects for teacher '{}' year '{}'", teacherId, academicYear); - return teacherSubjectRepository - .findByTeacherIdAndAcademicYear(teacherId, academicYear, pageable) - .map(TeacherSubjectMapper::toResponseDto); + List content = teacherSubjectRepository + .findByTeacherIdAndAcademicYear(teacherId, academicYear) + .stream() + .map(TeacherSubjectMapper::toResponseDto) + .toList(); + return new PageImpl<>(content, pageable, content.size()); } @Override @@ -124,9 +142,13 @@ public Page findBySubjectAndYear( String subjectId, String academicYear, Pageable pageable) { log.debug("Fetching teachers for subject '{}' year '{}'", subjectId, academicYear); findSubjectById(subjectId); - return teacherSubjectRepository - .findBySubjectIdAndAcademicYear(subjectId, academicYear, pageable) - .map(TeacherSubjectMapper::toResponseDto); + List raw = (academicYear != null && !academicYear.isBlank()) + ? teacherSubjectRepository.findBySubjectIdAndAcademicYear(subjectId, academicYear) + : teacherSubjectRepository.findBySubjectId(subjectId); + List content = raw.stream() + .map(TeacherSubjectMapper::toResponseDto) + .toList(); + return new PageImpl<>(content, pageable, content.size()); } @Override @@ -134,9 +156,24 @@ public Page findBySubjectAndYear( public Page findBySchoolAndYear( String schoolId, String academicYear, Pageable pageable) { log.debug("Fetching teacher subjects for school '{}' year '{}'", schoolId, academicYear); - return teacherSubjectRepository - .findBySchoolIdAndAcademicYear(schoolId, academicYear, pageable) - .map(TeacherSubjectMapper::toResponseDto); + List content = teacherSubjectRepository + .findBySchoolIdAndAcademicYear(schoolId, academicYear) + .stream() + .map(TeacherSubjectMapper::toResponseDto) + .toList(); + return new PageImpl<>(content, pageable, content.size()); + } + + @Override + @Transactional(readOnly = true) + public Page findBySchool(String schoolId, Pageable pageable) { + log.debug("Fetching all teacher subjects for school '{}'", schoolId); + List content = teacherSubjectRepository + .findBySchoolId(schoolId) + .stream() + .map(TeacherSubjectMapper::toResponseDto) + .toList(); + return new PageImpl<>(content, pageable, content.size()); } @Override @@ -152,7 +189,7 @@ public void delete(String id) { // ================================================================ private TeacherSubject findEntityById(String id) { - return teacherSubjectRepository.findById(id) + return teacherSubjectRepository.findByIdWithGraph(id) .orElseThrow(() -> new ResourceNotFoundException( "Teacher subject assignment not found with id: " + id )); diff --git a/services/academic-service/src/main/resources/db/migration/V6__edit_academic_year_name.sql b/services/academic-service/src/main/resources/db/migration/V6__edit_academic_year_name.sql new file mode 100644 index 0000000..1bb1541 --- /dev/null +++ b/services/academic-service/src/main/resources/db/migration/V6__edit_academic_year_name.sql @@ -0,0 +1,2 @@ +ALTER TABLE academic_years + ADD CONSTRAINT uc_academic_years_school_name UNIQUE (school_id, name); diff --git a/services/academic-service/src/main/resources/db/migration/V7__fix_grade_level_constraints.sql b/services/academic-service/src/main/resources/db/migration/V7__fix_grade_level_constraints.sql new file mode 100644 index 0000000..f155702 --- /dev/null +++ b/services/academic-service/src/main/resources/db/migration/V7__fix_grade_level_constraints.sql @@ -0,0 +1,24 @@ +-- DO $$ +-- BEGIN +-- -- Drop the old constraint if it exists +-- IF EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'uc_grade_levels_display_order') THEN +-- ALTER TABLE grade_levels DROP CONSTRAINT uc_grade_levels_display_order; +-- END IF; +-- +-- -- Add new constraint on (school_id, display_order) if not exists +-- IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'uc_grade_levels_school_display_order') THEN +-- ALTER TABLE grade_levels ADD CONSTRAINT uc_grade_levels_school_display_order UNIQUE (school_id, display_order); +-- END IF; +-- +-- -- Add new constraint on (school_id, name) if not exists +-- IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'uc_grade_levels_school_name') THEN +-- ALTER TABLE grade_levels ADD CONSTRAINT uc_grade_levels_school_name UNIQUE (school_id, name); +-- END IF; +-- END $$; +-- + +ALTER TABLE grade_levels + ADD CONSTRAINT uc_grade_levels_school_display_order UNIQUE (school_id, display_order); + +ALTER TABLE grade_levels + ADD CONSTRAINT uc_grade_levels_school_name UNIQUE (school_id, name); diff --git a/services/academic-service/src/main/resources/db/migration/V8__fix_academic_year_constraints.sql b/services/academic-service/src/main/resources/db/migration/V8__fix_academic_year_constraints.sql new file mode 100644 index 0000000..1b9fd76 --- /dev/null +++ b/services/academic-service/src/main/resources/db/migration/V8__fix_academic_year_constraints.sql @@ -0,0 +1,16 @@ +DO $$ +BEGIN + -- Drop the incorrect unique constraint on name if it exists + IF EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'uc_academic_years_name') THEN + ALTER TABLE academic_years DROP CONSTRAINT uc_academic_years_name; + END IF; + + -- Ensure the correct unique constraint on (school_id, name) exists + -- The table already had "uc_academic_years_school_name" in the output from \d academic_years, + -- but this ensures it's enforced. + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'uc_academic_years_school_name') THEN + ALTER TABLE academic_years ADD CONSTRAINT uc_academic_years_school_name UNIQUE (school_id, name); + END IF; + +END $$; + diff --git a/services/api-gateway/pom.xml b/services/api-gateway/pom.xml index 3e40a4e..516af7d 100644 --- a/services/api-gateway/pom.xml +++ b/services/api-gateway/pom.xml @@ -62,4 +62,4 @@ - \ No newline at end of file + diff --git a/services/api-gateway/src/main/java/com/apigateway/config/GatewaySecurityConfig.java b/services/api-gateway/src/main/java/com/apigateway/config/GatewaySecurityConfig.java index fa27288..2642264 100644 --- a/services/api-gateway/src/main/java/com/apigateway/config/GatewaySecurityConfig.java +++ b/services/api-gateway/src/main/java/com/apigateway/config/GatewaySecurityConfig.java @@ -32,8 +32,8 @@ public SecurityWebFilterChain springSecurityFilterChain( .authorizeExchange(exchanges -> exchanges .pathMatchers(HttpMethod.OPTIONS, "/**").permitAll() .pathMatchers( - "/api/auth/**", - "/api/admin/super-admin", + "/api/auth/v1/auth/**", + "/api/admin/v1/admin/super-admin", "/api/schools/v1/schools/register", "/actuator/**" ).permitAll() diff --git a/services/config-service/src/main/resources/config-repo/api-gateway.yml b/services/config-service/src/main/resources/config-repo/api-gateway.yml index 8f008dc..cc34221 100644 --- a/services/config-service/src/main/resources/config-repo/api-gateway.yml +++ b/services/config-service/src/main/resources/config-repo/api-gateway.yml @@ -115,4 +115,4 @@ logging: level: org.springframework.cloud.gateway: DEBUG org.springframework.security: DEBUG - reactor.netty: INFO \ No newline at end of file + reactor.netty: INFO diff --git a/services/user-service/pom.xml b/services/user-service/pom.xml index 493f788..7423554 100644 --- a/services/user-service/pom.xml +++ b/services/user-service/pom.xml @@ -186,4 +186,4 @@ - \ No newline at end of file +