diff --git a/src/main/java/com/ericbouchut/learndev/auth/AuthController.java b/src/main/java/com/ericbouchut/learndev/auth/AuthController.java index b134e44..b49c57c 100644 --- a/src/main/java/com/ericbouchut/learndev/auth/AuthController.java +++ b/src/main/java/com/ericbouchut/learndev/auth/AuthController.java @@ -13,8 +13,9 @@ /** * Web endpoints for authentication pages: - * home, login and dashboard views, and the registration form + * home and login views, and the registration form * (display and submission). + * The dashboard lives in the course package (it renders enrollments). * Spring Security handles the login POST and logout itself. * This controller renders the pages around them. */ @@ -47,15 +48,6 @@ public String login() { return "login"; } - /** - * Display the dashboard page. - * @return the name of the dashboard template - */ - @GetMapping("/dashboard") - public String dashboard() { - return "dashboard"; - } - /** * Display the User registration form. * @param model 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..80d88cb --- /dev/null +++ b/src/main/java/com/ericbouchut/learndev/course/CourseController.java @@ -0,0 +1,177 @@ +package com.ericbouchut.learndev.course; + +import com.ericbouchut.learndev.course.entity.Course; +import com.ericbouchut.learndev.course.entity.Enrollment; +import com.ericbouchut.learndev.course.entity.EnrollmentStatus; +import com.ericbouchut.learndev.course.entity.Lesson; +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 org.springframework.web.bind.annotation.PostMapping; + +import java.security.Principal; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Web endpoints of the student course experience: the published + * course catalogue, the course detail page, and the enroll/drop actions. + * Access requires authentication ({@code /courses/**} in the security + * rules); visibility of individual courses is decided by + * {@link CourseService} and the enrollment lifecycle by + * {@link EnrollmentService}. + */ +@Controller +public class CourseController { + + private final CourseService courseService; + private final EnrollmentService enrollmentService; + private final MarkdownRenderer markdownRenderer; + private final UserRepository users; + + public CourseController( + CourseService courseService, + EnrollmentService enrollmentService, + MarkdownRenderer markdownRenderer, + UserRepository users + ) { + this.courseService = courseService; + this.enrollmentService = enrollmentService; + this.markdownRenderer = markdownRenderer; + this.users = users; + } + + /** + * Display the catalogue of published courses, with the logged-in + * student's active enrollments marked. + * + * @param principal the logged-in student + * @param model receives the courses and the enrolled course ids + * @return the catalogue view name + */ + @GetMapping("/courses") + public String catalogue(Principal principal, Model model) { + Map enrollmentByCourseId = + enrollmentService.myEnrollments(currentUser(principal)).stream() + .filter(e -> e.getStatus() != EnrollmentStatus.DROPPED) + .collect(Collectors.toMap( + e -> e.getCourse().getCourseId(), + Enrollment::getStatus)); + model.addAttribute("courses", courseService.catalogue()); + model.addAttribute("enrollmentByCourseId", enrollmentByCourseId); + return "courses/catalog"; + } + + /** + * Display one course with its published lessons and the enroll or quit + * action matching the student's enrollment state. + * + * @param courseId the course to show + * @param principal the logged-in student (drives visibility) + * @param model receives the course, its readable lessons, and the + * student's enrollment (null when never enrolled) + * @return the course detail view name + */ + @GetMapping("/courses/{courseId}") + public String detail(@PathVariable Long courseId, Principal principal, Model model) { + User student = currentUser(principal); + Course course = courseService.visibleCourse(courseId, student); + model.addAttribute("course", course); + model.addAttribute("lessons", courseService.publishedLessons(course)); + model.addAttribute("enrollment", + enrollmentService.enrollmentFor(student, course).orElse(null)); + return "courses/detail"; + } + + /** + * Register the student in the course, then redirect back to the course + * page with an {@code enrolled} confirmation flag (PRG). + * + * @param courseId the course to join + * @param principal the logged-in student + * @return a redirect to the course detail page + */ + @PostMapping("/courses/{courseId}/enroll") + public String enroll(@PathVariable Long courseId, Principal principal) { + User student = currentUser(principal); + Course course = courseService.visibleCourse(courseId, student); + enrollmentService.enroll(student, course); + return "redirect:/courses/" + courseId + "?enrolled"; + } + + /** + * Drop the student from the course, then redirect back to the course + * page with a {@code dropped} confirmation flag (PRG). + * + * @param courseId the course to leave + * @param principal the logged-in student + * @return a redirect to the course detail page + */ + @PostMapping("/courses/{courseId}/drop") + public String drop(@PathVariable Long courseId, Principal principal) { + User student = currentUser(principal); + Course course = courseService.visibleCourse(courseId, student); + enrollmentService.drop(student, course); + return "redirect:/courses/" + courseId + "?dropped"; + } + + /** + * Display one published lesson to an enrolled student, with the lesson + * Markdown rendered to sanitized HTML and previous/next links in + * reading order. A student who is not actively enrolled is sent back to + * the course page with an {@code enroll-required} hint instead of a 403: + * the Register button is right there. + * + * @param courseId the course the lesson belongs to + * @param lessonId the lesson to read + * @param principal the logged-in student + * @param model receives the course, lesson, rendered content, and + * the previous/next lessons (null at the edges) + * @return the lesson view name, or a redirect when not enrolled + */ + @GetMapping("/courses/{courseId}/lessons/{lessonId}") + public String lesson( + @PathVariable Long courseId, + @PathVariable Long lessonId, + Principal principal, + Model model + ) { + User student = currentUser(principal); + Course course = courseService.visibleCourse(courseId, student); + boolean activelyEnrolled = enrollmentService.enrollmentFor(student, course) + .filter(e -> e.getStatus() != EnrollmentStatus.DROPPED) + .isPresent(); + if (!activelyEnrolled) { + return "redirect:/courses/" + courseId + "?enroll-required"; + } + Lesson lesson = courseService.publishedLesson(course, lessonId); + List lessons = courseService.publishedLessons(course); + int index = indexOf(lessons, lesson); + model.addAttribute("course", course); + model.addAttribute("lesson", lesson); + model.addAttribute("contentHtml", markdownRenderer.render(lesson.getContentMarkdown())); + model.addAttribute("previousLesson", index > 0 ? lessons.get(index - 1) : null); + model.addAttribute("nextLesson", + index < lessons.size() - 1 ? lessons.get(index + 1) : null); + return "courses/lesson"; + } + + private static int indexOf(List lessons, Lesson lesson) { + for (int i = 0; i < lessons.size(); i++) { + if (lessons.get(i).getLessonId().equals(lesson.getLessonId())) { + return i; + } + } + return -1; + } + + 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/DashboardController.java b/src/main/java/com/ericbouchut/learndev/course/DashboardController.java new file mode 100644 index 0000000..deddaf0 --- /dev/null +++ b/src/main/java/com/ericbouchut/learndev/course/DashboardController.java @@ -0,0 +1,48 @@ +package com.ericbouchut.learndev.course; + +import com.ericbouchut.learndev.course.entity.EnrollmentStatus; +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 java.security.Principal; + +/** + * Web endpoint of the student dashboard: the logged-in user's active + * courses with their enrollment status. Lives in the course package because + * the page renders enrollment data (the auth package only brings the user + * here after login). + */ +@Controller +public class DashboardController { + + private final EnrollmentService enrollmentService; + private final UserRepository users; + + public DashboardController(EnrollmentService enrollmentService, UserRepository users) { + this.enrollmentService = enrollmentService; + this.users = users; + } + + /** + * Display the dashboard with the student's active enrollments + * (dropped ones are not listed; re-enrolling brings them back). + * + * @param principal the logged-in user + * @param model receives the active enrollments + * @return the dashboard view name + */ + @GetMapping("/dashboard") + public String dashboard(Principal principal, Model model) { + User user = users.findByUsername(principal.getName()) + .orElseThrow(() -> new IllegalStateException( + "Authenticated user not found: " + principal.getName())); + model.addAttribute("enrollments", + enrollmentService.myEnrollments(user).stream() + .filter(e -> e.getStatus() != EnrollmentStatus.DROPPED) + .toList()); + return "dashboard"; + } +} diff --git a/src/main/java/com/ericbouchut/learndev/course/EnrollmentService.java b/src/main/java/com/ericbouchut/learndev/course/EnrollmentService.java new file mode 100644 index 0000000..10bd56a --- /dev/null +++ b/src/main/java/com/ericbouchut/learndev/course/EnrollmentService.java @@ -0,0 +1,89 @@ +package com.ericbouchut.learndev.course; + +import com.ericbouchut.learndev.course.entity.Course; +import com.ericbouchut.learndev.course.entity.Enrollment; +import com.ericbouchut.learndev.course.entity.EnrollmentStatus; +import com.ericbouchut.learndev.course.entity.PublicationStatus; +import com.ericbouchut.learndev.course.repository.EnrollmentRepository; +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.time.OffsetDateTime; +import java.util.List; +import java.util.Optional; + +/** + * Student-side enrollment lifecycle (see the diagram in CONTRIBUTING.md). + * The (user, course) pair is the primary key, so a student has at most one + * enrollment row per course: enrolling again re-activates a {@code DROPPED} + * row instead of inserting a second one, and both operations are idempotent + * (re-posting a form must not fail). + */ +@Service +public class EnrollmentService { + + private final EnrollmentRepository enrollments; + + public EnrollmentService(EnrollmentRepository enrollments) { + this.enrollments = enrollments; + } + + /** + * Enroll the student in a published course. A {@code DROPPED} enrollment + * is re-activated (back to {@code ENROLLED}, drop timestamp cleared, + * enrollment timestamp refreshed); an active one is left untouched. + * + * @throws ResponseStatusException 404 when the course is not published + * (only published courses accept new enrollments) + */ + @Transactional + public void enroll(User student, Course course) { + Optional existing = enrollments.findByUserAndCourse(student, course); + if (existing.isPresent() && existing.get().getStatus() != EnrollmentStatus.DROPPED) { + return; + } + if (course.getStatus() != PublicationStatus.PUBLISHED) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND); + } + if (existing.isPresent()) { + Enrollment enrollment = existing.get(); + enrollment.setStatus(EnrollmentStatus.ENROLLED); + enrollment.setDroppedAt(null); + enrollment.setEnrolledAt(OffsetDateTime.now()); + enrollments.save(enrollment); + } else { + enrollments.save(new Enrollment(student, course)); + } + } + + /** + * Drop the student from a course: {@code ENROLLED} or + * {@code IN_PROGRESS} becomes {@code DROPPED} with a drop timestamp. + * No-op when the student is not enrolled, already dropped, or completed + * the course (the lifecycle has no transition out of COMPLETED). + */ + @Transactional + public void drop(User student, Course course) { + enrollments.findByUserAndCourse(student, course) + .filter(enrollment -> enrollment.getStatus() == EnrollmentStatus.ENROLLED + || enrollment.getStatus() == EnrollmentStatus.IN_PROGRESS) + .ifPresent(enrollment -> { + enrollment.setStatus(EnrollmentStatus.DROPPED); + enrollment.setDroppedAt(OffsetDateTime.now()); + enrollments.save(enrollment); + }); + } + + /** The enrollment of a student in a course, if any (dropped included). */ + public Optional enrollmentFor(User student, Course course) { + return enrollments.findByUserAndCourse(student, course); + } + + /** All enrollments of a student, dropped included (the dashboard filters). */ + public List myEnrollments(User student) { + return enrollments.findByUser(student); + } +} 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..4f9b051 --- /dev/null +++ b/src/main/resources/templates/courses/catalog.html @@ -0,0 +1,34 @@ + + + + + + +
+

Courses

+ +

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

+ +
    +
  • +

    + Course title +

    +

    + By instructor + + · You are enrolled +

    +

    + 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..b022a90 --- /dev/null +++ b/src/main/resources/templates/courses/detail.html @@ -0,0 +1,55 @@ + + + + + + +
+

+ You are enrolled in this course. +

+

+ You have left this course. +

+

+ Register for the course to read its lessons. +

+ +

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/courses/lesson.html b/src/main/resources/templates/courses/lesson.html new file mode 100644 index 0000000..1e1a220 --- /dev/null +++ b/src/main/resources/templates/courses/lesson.html @@ -0,0 +1,33 @@ + + + + + + +
+

+ Course title +

+ +
+

Lesson title

+ +
Lesson content
+
+ + +
+ + + + diff --git a/src/main/resources/templates/dashboard.html b/src/main/resources/templates/dashboard.html index bf56e38..93283af 100644 --- a/src/main/resources/templates/dashboard.html +++ b/src/main/resources/templates/dashboard.html @@ -12,10 +12,26 @@

Your courses

-

- No courses yet. Course enrollment arrives with the course catalog; - this is where your progress will live. + +

+ You are not enrolled in any course yet. + Browse the courses to get started.

+ +
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 @@