Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 2 additions & 10 deletions src/main/java/com/ericbouchut/learndev/auth/AuthController.java
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@

/**
* <b>Web</b> 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.
*/
Expand Down Expand Up @@ -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
Expand Down
177 changes: 177 additions & 0 deletions src/main/java/com/ericbouchut/learndev/course/CourseController.java
Original file line number Diff line number Diff line change
@@ -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;

/**
* <b>Web</b> 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<Long, EnrollmentStatus> 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<Lesson> 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<Lesson> 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()));
}
}
87 changes: 87 additions & 0 deletions src/main/java/com/ericbouchut/learndev/course/CourseService.java
Original file line number Diff line number Diff line change
@@ -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<Course> 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<Lesson> 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);
}
}
Original file line number Diff line number Diff line change
@@ -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;

/**
* <b>Web</b> 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";
}
}
Loading