CoursesController.java

package edu.ucsb.cs156.frontiers.controllers;

import com.fasterxml.jackson.core.JsonProcessingException;
import edu.ucsb.cs156.frontiers.entities.Course;
import edu.ucsb.cs156.frontiers.entities.CourseStaff;
import edu.ucsb.cs156.frontiers.entities.RosterStudent;
import edu.ucsb.cs156.frontiers.entities.User;
import edu.ucsb.cs156.frontiers.enums.OrgStatus;
import edu.ucsb.cs156.frontiers.errors.EntityNotFoundException;
import edu.ucsb.cs156.frontiers.errors.InvalidInstallationTypeException;
import edu.ucsb.cs156.frontiers.models.CurrentUser;
import edu.ucsb.cs156.frontiers.repositories.AdminRepository;
import edu.ucsb.cs156.frontiers.repositories.CourseRepository;
import edu.ucsb.cs156.frontiers.repositories.CourseStaffRepository;
import edu.ucsb.cs156.frontiers.repositories.InstructorRepository;
import edu.ucsb.cs156.frontiers.repositories.RosterStudentRepository;
import edu.ucsb.cs156.frontiers.repositories.UserRepository;
import edu.ucsb.cs156.frontiers.services.OrganizationLinkerService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import java.security.NoSuchAlgorithmException;
import java.security.spec.InvalidKeySpecException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;

@Tag(name = "Course")
@RequestMapping("/api/courses")
@RestController
@Slf4j
public class CoursesController extends ApiController {

  @Autowired private CourseRepository courseRepository;

  @Autowired private UserRepository userRepository;

  @Autowired private RosterStudentRepository rosterStudentRepository;

  @Autowired private CourseStaffRepository courseStaffRepository;

  @Autowired private InstructorRepository instructorRepository;

  @Autowired private AdminRepository adminRepository;

  @Autowired private OrganizationLinkerService linkerService;

  /**
   * This method creates a new Course.
   *
   * @param courseName the name of the course
   * @param term the term of the course
   * @param school the school of the course
   * @return the created course
   */
  @Operation(summary = "Create a new course")
  @PreAuthorize("hasRole('ROLE_ADMIN') || hasRole('ROLE_INSTRUCTOR')")
  @PostMapping("/post")
  public InstructorCourseView postCourse(
      @Parameter(name = "courseName") @RequestParam String courseName,
      @Parameter(name = "term") @RequestParam String term,
      @Parameter(name = "school") @RequestParam String school) {
    // get current date right now and set status to pending
    CurrentUser currentUser = getCurrentUser();
    Course course =
        Course.builder()
            .courseName(courseName)
            .term(term)
            .school(school)
            .instructorEmail(currentUser.getUser().getEmail())
            .build();
    Course savedCourse = courseRepository.save(course);

    return new InstructorCourseView(savedCourse);
  }

  /** Projection of Course entity with fields that are relevant for instructors and admins */
  public static record InstructorCourseView(
      Long id,
      String installationId,
      String orgName,
      String courseName,
      String term,
      String school,
      String instructorEmail,
      int numStudents,
      int numStaff) {

    // Creates view from Course entity
    public InstructorCourseView(Course c) {
      this(
          c.getId(),
          c.getInstallationId(),
          c.getOrgName(),
          c.getCourseName(),
          c.getTerm(),
          c.getSchool(),
          c.getInstructorEmail(),
          c.getRosterStudents() != null ? c.getRosterStudents().size() : 0,
          c.getCourseStaff() != null ? c.getCourseStaff().size() : 0);
    }
  }

  /**
   * This method returns a list of courses.
   *
   * @return a list of all courses for an instructor.
   */
  @Operation(summary = "List all courses for an instructor")
  @PreAuthorize("hasRole('ROLE_INSTRUCTOR')")
  @GetMapping("/allForInstructors")
  public Iterable<InstructorCourseView> allForInstructors() {
    CurrentUser currentUser = getCurrentUser();
    String instructorEmail = currentUser.getUser().getEmail();
    List<Course> courses = courseRepository.findByInstructorEmail(instructorEmail);

    List<InstructorCourseView> courseViews =
        courses.stream().map(InstructorCourseView::new).collect(Collectors.toList());
    return courseViews;
  }

  /**
   * This method returns a list of courses.
   *
   * @return a list of all courses for an admin.
   */
  @Operation(summary = "List all courses for an admin")
  @PreAuthorize("hasRole('ROLE_ADMIN')")
  @GetMapping("/allForAdmins")
  public Iterable<InstructorCourseView> allForAdmins() {
    List<Course> courses = courseRepository.findAll();

    List<InstructorCourseView> courseViews =
        courses.stream().map(InstructorCourseView::new).collect(Collectors.toList());
    return courseViews;
  }

  /**
   * This method returns single course by its id
   *
   * @return a course
   */
  @Operation(summary = "Get course by id")
  @PreAuthorize("@CourseSecurity.hasManagePermissions(#root, #id)")
  @GetMapping("/{id}")
  public InstructorCourseView getCourseById(@Parameter(name = "id") @PathVariable Long id) {
    Course course =
        courseRepository
            .findById(id)
            .orElseThrow(() -> new EntityNotFoundException(Course.class, id));
    // Convert to InstructorCourseView
    InstructorCourseView courseView = new InstructorCourseView(course);
    return courseView;
  }

  /**
   * This is the outgoing method, redirecting from Frontiers to GitHub to allow a Course to be
   * linked to a GitHub Organization. It redirects from Frontiers to the GitHub app installation
   * process, and will return with the {@link #addInstallation(Optional, String, String, Long)
   * addInstallation()} endpoint
   *
   * @param courseId id of the course to be linked to
   * @return dynamically loaded url to install Frontiers to a Github Organization, with the courseId
   *     marked as the state parameter, which GitHub will return.
   */
  @Operation(summary = "Authorize Frontiers to a Github Course")
  @PreAuthorize("@CourseSecurity.hasManagePermissions(#root, #courseId)")
  @GetMapping("/redirect")
  public ResponseEntity<Void> linkCourse(@Parameter Long courseId)
      throws JsonProcessingException, NoSuchAlgorithmException, InvalidKeySpecException {
    String newUrl = linkerService.getRedirectUrl();
    newUrl += "/installations/new?state=" + courseId;
    // found this convenient solution here:
    // https://stackoverflow.com/questions/29085295/spring-mvc-restcontroller-and-redirect
    return ResponseEntity.status(HttpStatus.MOVED_PERMANENTLY)
        .header(HttpHeaders.LOCATION, newUrl)
        .build();
  }

  /**
   * @param installation_id id of the incoming GitHub Organization installation
   * @param setup_action whether the permissions are installed or updated. Required RequestParam but
   *     not used by the method.
   * @param code token to be exchanged with GitHub to ensure the request is legitimate and not
   *     spoofed.
   * @param state id of the Course to be linked with the GitHub installation.
   * @return ResponseEntity, returning /success if the course was successfully linked or /noperms if
   *     the user does not have the permission to install the application on GitHub. Alternately
   *     returns 403 Forbidden if the user is not the creator.
   */
  @Operation(summary = "Link a Course to a Github Organization by installing Github App")
  @PreAuthorize("hasRole('ROLE_ADMIN') || hasRole('ROLE_INSTRUCTOR')")
  @GetMapping("link")
  public ResponseEntity<Void> addInstallation(
      @Parameter(name = "installationId") @RequestParam Optional<String> installation_id,
      @Parameter(name = "setupAction") @RequestParam String setup_action,
      @Parameter(name = "code") @RequestParam String code,
      @Parameter(name = "state") @RequestParam Long state)
      throws NoSuchAlgorithmException, InvalidKeySpecException, JsonProcessingException {
    if (installation_id.isEmpty()) {
      return ResponseEntity.status(HttpStatus.MOVED_PERMANENTLY)
          .header(HttpHeaders.LOCATION, "/courses/nopermissions")
          .build();
    } else {
      Course course =
          courseRepository
              .findById(state)
              .orElseThrow(() -> new EntityNotFoundException(Course.class, state));
      if (!isCurrentUserAdmin()
          && !course.getInstructorEmail().equals(getCurrentUser().getUser().getEmail())) {
        return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
      } else {
        String orgName = linkerService.getOrgName(installation_id.get());
        course.setInstallationId(installation_id.get());
        course.setOrgName(orgName);
        course
            .getRosterStudents()
            .forEach(
                rs -> {
                  rs.setOrgStatus(OrgStatus.JOINCOURSE);
                });
        course
            .getCourseStaff()
            .forEach(
                cs -> {
                  cs.setOrgStatus(OrgStatus.JOINCOURSE);
                });
        courseRepository.save(course);
        return ResponseEntity.status(HttpStatus.MOVED_PERMANENTLY)
            .header(HttpHeaders.LOCATION, "/login/success")
            .build();
      }
    }
  }

  /**
   * This method handles the InvalidInstallationTypeException.
   *
   * @param e the exception
   * @return a map with the type and message of the exception
   */
  @ExceptionHandler({InvalidInstallationTypeException.class})
  @ResponseStatus(HttpStatus.BAD_REQUEST)
  public Object handleInvalidInstallationType(Throwable e) {
    return Map.of(
        "type", e.getClass().getSimpleName(),
        "message", e.getMessage());
  }

  public record RosterStudentCoursesDTO(
      Long id,
      String installationId,
      String orgName,
      String courseName,
      String term,
      String school,
      OrgStatus studentStatus,
      Long rosterStudentId) {}

  /**
   * This method returns a list of courses that the current user is enrolled.
   *
   * @return a list of courses in the DTO form along with the student status in the organization.
   */
  @Operation(summary = "List all courses for the current student, including their org status")
  @PreAuthorize("hasRole('ROLE_USER')")
  @GetMapping("/list")
  public List<RosterStudentCoursesDTO> listCoursesForCurrentUser() {
    String email = getCurrentUser().getUser().getEmail();
    Iterable<RosterStudent> rosterStudentsIterable = rosterStudentRepository.findAllByEmail(email);
    List<RosterStudent> rosterStudents = new ArrayList<>();
    rosterStudentsIterable.forEach(rosterStudents::add);
    return rosterStudents.stream()
        .map(
            rs -> {
              Course course = rs.getCourse();
              RosterStudentCoursesDTO rsDto =
                  new RosterStudentCoursesDTO(
                      course.getId(),
                      course.getInstallationId(),
                      course.getOrgName(),
                      course.getCourseName(),
                      course.getTerm(),
                      course.getSchool(),
                      rs.getOrgStatus(),
                      rs.getId());
              return rsDto;
            })
        .collect(Collectors.toList());
  }

  public record StaffCoursesDTO(
      Long id,
      String installationId,
      String orgName,
      String courseName,
      String term,
      String school,
      OrgStatus studentStatus,
      Long staffId) {}

  /**
   * student see what courses they appear as staff in
   *
   * @param studentId the id of the student making request
   * @return a list of all courses student is staff in
   */
  @Operation(summary = "Student see what courses they appear as staff in")
  @PreAuthorize("hasRole('ROLE_USER')")
  @GetMapping("/staffCourses")
  public List<StaffCoursesDTO> staffCourses() {
    CurrentUser currentUser = getCurrentUser();
    User user = currentUser.getUser();

    String email = user.getEmail();

    List<CourseStaff> staffMembers = courseStaffRepository.findAllByEmail(email);
    return staffMembers.stream()
        .map(
            s -> {
              Course course = s.getCourse();
              StaffCoursesDTO sDto =
                  new StaffCoursesDTO(
                      course.getId(),
                      course.getInstallationId(),
                      course.getOrgName(),
                      course.getCourseName(),
                      course.getTerm(),
                      course.getSchool(),
                      s.getOrgStatus(),
                      s.getId());
              return sDto;
            })
        .collect(Collectors.toList());
  }

  @Operation(summary = "Update instructor email for a course (admin only)")
  @PreAuthorize("hasRole('ROLE_ADMIN')")
  @PutMapping("/updateInstructor")
  public InstructorCourseView updateInstructorEmail(
      @Parameter(name = "courseId") @RequestParam Long courseId,
      @Parameter(name = "instructorEmail") @RequestParam String instructorEmail) {

    Course course =
        courseRepository
            .findById(courseId)
            .orElseThrow(() -> new EntityNotFoundException(Course.class, courseId));

    // Validate that the email exists in either instructor or admin table
    boolean isInstructor = instructorRepository.existsByEmail(instructorEmail);
    boolean isAdmin = adminRepository.existsByEmail(instructorEmail);

    if (!isInstructor && !isAdmin) {
      throw new IllegalArgumentException("Email must belong to either an instructor or admin");
    }

    course.setInstructorEmail(instructorEmail);
    Course savedCourse = courseRepository.save(course);

    return new InstructorCourseView(savedCourse);
  }

  @Operation(summary = "Delete a course")
  @PreAuthorize("hasRole('ROLE_ADMIN')")
  @DeleteMapping("")
  public Object deleteCourse(@RequestParam Long courseId)
      throws NoSuchAlgorithmException, InvalidKeySpecException {
    Course course =
        courseRepository
            .findById(courseId)
            .orElseThrow(() -> new EntityNotFoundException(Course.class, courseId));

    // Check if course has roster students or staff
    if (!course.getRosterStudents().isEmpty() || !course.getCourseStaff().isEmpty()) {
      throw new IllegalArgumentException("Cannot delete course with students or staff");
    }

    linkerService.unenrollOrganization(course);
    courseRepository.delete(course);
    return genericMessage("Course with id %s deleted".formatted(course.getId()));
  }

  /**
   * This method updates an existing course.
   *
   * @param courseId the id of the course to update
   * @param courseName the new name of the course
   * @param term the new term of the course
   * @param school the new school of the course
   * @return the updated course
   */
  @Operation(summary = "Update an existing course")
  @PreAuthorize("@CourseSecurity.hasManagePermissions(#root, #courseId)")
  @PutMapping("")
  public InstructorCourseView updateCourse(
      @Parameter(name = "courseId") @RequestParam Long courseId,
      @Parameter(name = "courseName") @RequestParam String courseName,
      @Parameter(name = "term") @RequestParam String term,
      @Parameter(name = "school") @RequestParam String school) {
    Course course =
        courseRepository
            .findById(courseId)
            .orElseThrow(() -> new EntityNotFoundException(Course.class, courseId));

    course.setCourseName(courseName);
    course.setTerm(term);
    course.setSchool(school);

    Course savedCourse = courseRepository.save(course);

    return new InstructorCourseView(savedCourse);
  }
}