From 0d48dac8c33213295020e4c82077c4b7715f3eeb Mon Sep 17 00:00:00 2001 From: Eric Bouchut Date: Mon, 13 Jul 2026 10:14:42 +0200 Subject: [PATCH 1/4] feat(course): Add the published course catalogue Students can browse the published courses at /courses and open a course page listing its published lessons. CourseService owns the student visibility rule: PUBLISHED is visible, an enrolled student keeps read access to an ARCHIVED course, DRAFT is never visible, and invisible content answers 404 so drafts do not leak. New derived queries: lessons by course and status in reading order, enrollment by (user, course). The navigation gains a Courses entry for logged-in users, and the security matrix tightens /courses from gate-pass 404 to 200 now that the controller exists. --- .../learndev/course/CourseController.java | 63 ++++++++++++++ .../learndev/course/CourseService.java | 87 +++++++++++++++++++ .../repository/EnrollmentRepository.java | 4 + .../course/repository/LessonRepository.java | 4 + .../resources/templates/courses/catalog.html | 32 +++++++ .../resources/templates/courses/detail.html | 30 +++++++ .../resources/templates/fragments/layout.html | 6 +- .../common/config/SecurityMatrixTest.java | 2 +- .../CourseDomainRepositoryTest.java | 47 ++++++++++ 9 files changed, 272 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/ericbouchut/learndev/course/CourseController.java create mode 100644 src/main/java/com/ericbouchut/learndev/course/CourseService.java create mode 100644 src/main/resources/templates/courses/catalog.html create mode 100644 src/main/resources/templates/courses/detail.html diff --git a/src/main/java/com/ericbouchut/learndev/course/CourseController.java b/src/main/java/com/ericbouchut/learndev/course/CourseController.java new file mode 100644 index 0000000..81c16fa --- /dev/null +++ b/src/main/java/com/ericbouchut/learndev/course/CourseController.java @@ -0,0 +1,63 @@ +package com.ericbouchut.learndev.course; + +import com.ericbouchut.learndev.course.entity.Course; +import com.ericbouchut.learndev.user.entity.User; +import com.ericbouchut.learndev.user.repository.UserRepository; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; + +import java.security.Principal; + +/** + * Web endpoints of the student course experience: the published + * course catalogue and the course detail page. Access requires + * authentication ({@code /courses/**} in the security rules); visibility of + * individual courses is decided by {@link CourseService}. + */ +@Controller +public class CourseController { + + private final CourseService courseService; + private final UserRepository users; + + public CourseController(CourseService courseService, UserRepository users) { + this.courseService = courseService; + this.users = users; + } + + /** + * Display the catalogue of published courses. + * + * @param model receives the published courses + * @return the catalogue view name + */ + @GetMapping("/courses") + public String catalogue(Model model) { + model.addAttribute("courses", courseService.catalogue()); + return "courses/catalog"; + } + + /** + * Display one course with its published lessons. + * + * @param courseId the course to show + * @param principal the logged-in student (drives visibility) + * @param model receives the course and its readable lessons + * @return the course detail view name + */ + @GetMapping("/courses/{courseId}") + public String detail(@PathVariable Long courseId, Principal principal, Model model) { + Course course = courseService.visibleCourse(courseId, currentUser(principal)); + model.addAttribute("course", course); + model.addAttribute("lessons", courseService.publishedLessons(course)); + return "courses/detail"; + } + + private User currentUser(Principal principal) { + return users.findByUsername(principal.getName()) + .orElseThrow(() -> new IllegalStateException( + "Authenticated user not found: " + principal.getName())); + } +} diff --git a/src/main/java/com/ericbouchut/learndev/course/CourseService.java b/src/main/java/com/ericbouchut/learndev/course/CourseService.java new file mode 100644 index 0000000..8f1ddb2 --- /dev/null +++ b/src/main/java/com/ericbouchut/learndev/course/CourseService.java @@ -0,0 +1,87 @@ +package com.ericbouchut.learndev.course; + +import com.ericbouchut.learndev.course.entity.Course; +import com.ericbouchut.learndev.course.entity.Lesson; +import com.ericbouchut.learndev.course.entity.PublicationStatus; +import com.ericbouchut.learndev.course.repository.CourseRepository; +import com.ericbouchut.learndev.course.repository.EnrollmentRepository; +import com.ericbouchut.learndev.course.repository.LessonRepository; +import com.ericbouchut.learndev.user.entity.User; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; + +/** + * Read side of the course catalogue, applying the student visibility rule: + * a student sees {@code PUBLISHED} courses; a student who is enrolled keeps + * read access to the course and its published lessons if the course is later + * {@code ARCHIVED} (content continuity); {@code DRAFT} content is never + * visible to students. Invisible content surfaces as 404, not 403, so the + * URL space does not leak which drafts exist. + */ +@Service +@Transactional(readOnly = true) +public class CourseService { + + private final CourseRepository courses; + private final LessonRepository lessons; + private final EnrollmentRepository enrollments; + + public CourseService( + CourseRepository courses, + LessonRepository lessons, + EnrollmentRepository enrollments + ) { + this.courses = courses; + this.lessons = lessons; + this.enrollments = enrollments; + } + + /** The public catalogue: published courses only. */ + public List catalogue() { + return courses.findByStatus(PublicationStatus.PUBLISHED); + } + + /** + * The course as visible to the given student, or 404 when the course + * does not exist or the visibility rule (see class Javadoc) hides it. + */ + public Course visibleCourse(Long courseId, User student) { + Course course = courses.findById(courseId) + .orElseThrow(CourseService::notFound); + if (course.getStatus() == PublicationStatus.PUBLISHED) { + return course; + } + boolean enrolledInArchived = + course.getStatus() == PublicationStatus.ARCHIVED + && enrollments.findByUserAndCourse(student, course).isPresent(); + if (enrolledInArchived) { + return course; + } + throw notFound(); + } + + /** The lessons of a course a student may read, in reading order. */ + public List publishedLessons(Course course) { + return lessons.findByCourseAndStatusOrderByPositionAsc( + course, PublicationStatus.PUBLISHED); + } + + /** + * One published lesson of the given course, or 404 when the lesson does + * not exist, is not published, or belongs to another course. + */ + public Lesson publishedLesson(Course course, Long lessonId) { + return lessons.findById(lessonId) + .filter(lesson -> lesson.getCourse().getCourseId().equals(course.getCourseId())) + .filter(lesson -> lesson.getStatus() == PublicationStatus.PUBLISHED) + .orElseThrow(CourseService::notFound); + } + + private static ResponseStatusException notFound() { + return new ResponseStatusException(HttpStatus.NOT_FOUND); + } +} diff --git a/src/main/java/com/ericbouchut/learndev/course/repository/EnrollmentRepository.java b/src/main/java/com/ericbouchut/learndev/course/repository/EnrollmentRepository.java index 42186f8..088561a 100644 --- a/src/main/java/com/ericbouchut/learndev/course/repository/EnrollmentRepository.java +++ b/src/main/java/com/ericbouchut/learndev/course/repository/EnrollmentRepository.java @@ -7,12 +7,16 @@ import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; +import java.util.Optional; public interface EnrollmentRepository extends JpaRepository { /** A student's enrollments (their personal course list). */ List findByUser(User user); + /** The single enrollment of a student in a course, if any. */ + Optional findByUserAndCourse(User user, Course course); + /** The roster of a course. */ List findByCourse(Course course); } diff --git a/src/main/java/com/ericbouchut/learndev/course/repository/LessonRepository.java b/src/main/java/com/ericbouchut/learndev/course/repository/LessonRepository.java index ee9e3d4..b389bed 100644 --- a/src/main/java/com/ericbouchut/learndev/course/repository/LessonRepository.java +++ b/src/main/java/com/ericbouchut/learndev/course/repository/LessonRepository.java @@ -2,6 +2,7 @@ import com.ericbouchut.learndev.course.entity.Course; import com.ericbouchut.learndev.course.entity.Lesson; +import com.ericbouchut.learndev.course.entity.PublicationStatus; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; @@ -10,4 +11,7 @@ public interface LessonRepository extends JpaRepository { /** The lessons of a course in reading order (position ascending). */ List findByCourseOrderByPositionAsc(Course course); + + /** The lessons of a course in one status, in reading order. */ + List findByCourseAndStatusOrderByPositionAsc(Course course, PublicationStatus status); } diff --git a/src/main/resources/templates/courses/catalog.html b/src/main/resources/templates/courses/catalog.html new file mode 100644 index 0000000..db2eb40 --- /dev/null +++ b/src/main/resources/templates/courses/catalog.html @@ -0,0 +1,32 @@ + + + + + + +
+

Courses

+ +

+ No course is published yet. Come back soon! +

+ +
    +
  • +

    + Course title +

    +

    + By instructor +

    +

    + Course description +

    +
  • +
+
+ + + + diff --git a/src/main/resources/templates/courses/detail.html b/src/main/resources/templates/courses/detail.html new file mode 100644 index 0000000..20960f9 --- /dev/null +++ b/src/main/resources/templates/courses/detail.html @@ -0,0 +1,30 @@ + + + + + + +
+

Course title

+

+ By instructor +

+ +

+ Course description +

+ +
+

Lessons

+

No lesson is published yet.

+
    +
  1. Lesson title
  2. +
+
+ +

Back to all courses

+
+ + + + diff --git a/src/main/resources/templates/fragments/layout.html b/src/main/resources/templates/fragments/layout.html index 3dceece..56af4b5 100644 --- a/src/main/resources/templates/fragments/layout.html +++ b/src/main/resources/templates/fragments/layout.html @@ -4,8 +4,8 @@ - head(title): document head with the design stylesheets - header(current): skip link + banner + auth-aware navigation; `current` names the nav entry of the calling page ('home', 'login', - 'register', 'dashboard', 'instructor', 'admin' or null) and drives - aria-current="page" + 'register', 'dashboard', 'courses', 'instructor', 'admin' or null) and + drives aria-current="page" - footer: site footer Each page keeps its own (privacy.html is lang="fr"). --> @@ -39,6 +39,8 @@