diff --git a/src/backend/src/main/java/team/projectpulse/rubric/Criterion.java b/src/backend/src/main/java/team/projectpulse/rubric/Criterion.java new file mode 100644 index 0000000..8d42b8e --- /dev/null +++ b/src/backend/src/main/java/team/projectpulse/rubric/Criterion.java @@ -0,0 +1,42 @@ +package team.projectpulse.rubric; + +import jakarta.persistence.*; + +@Entity +@Table(name = "criteria") +public class Criterion { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "criterion_id") + private Long criterionId; + + @Column(nullable = false) + private String criterion; + + @Column(columnDefinition = "TEXT") + private String description; + + @Column(name = "max_score", nullable = false) + private int maxScore = 10; + + @Column(name = "course_id") + private Long courseId; + + // ── Getters / Setters ──────────────────────────────────────────────────── + + public Long getCriterionId() { return criterionId; } + public void setCriterionId(Long v) { this.criterionId = v; } + + public String getCriterion() { return criterion; } + public void setCriterion(String v) { this.criterion = v; } + + public String getDescription() { return description; } + public void setDescription(String v) { this.description = v; } + + public int getMaxScore() { return maxScore; } + public void setMaxScore(int v) { this.maxScore = v; } + + public Long getCourseId() { return courseId; } + public void setCourseId(Long v) { this.courseId = v; } +} diff --git a/src/backend/src/main/java/team/projectpulse/rubric/CriterionController.java b/src/backend/src/main/java/team/projectpulse/rubric/CriterionController.java new file mode 100644 index 0000000..188ebf5 --- /dev/null +++ b/src/backend/src/main/java/team/projectpulse/rubric/CriterionController.java @@ -0,0 +1,45 @@ +package team.projectpulse.rubric; + +import org.springframework.web.bind.annotation.*; +import team.projectpulse.common.Result; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("${api.endpoint.base-url}/criteria") +public class CriterionController { + + private final RubricService rubricService; + + public CriterionController(RubricService rubricService) { + this.rubricService = rubricService; + } + + /** POST /criteria/search */ + @PostMapping("/search") + public Result> search(@RequestBody CriterionSearchRequest body) { + List list = rubricService.searchCriteria(body.criterionId(), body.criterion()); + return Result.success(Map.of("content", list)); + } + + /** GET /criteria/{id} */ + @GetMapping("/{id}") + public Result findById(@PathVariable Long id) { + return Result.success(rubricService.findCriterionById(id)); + } + + /** POST /criteria */ + @PostMapping + public Result create(@RequestBody Criterion criterion) { + return Result.success("Criterion created.", rubricService.createCriterion(criterion)); + } + + /** PUT /criteria/{id} */ + @PutMapping("/{id}") + public Result update(@PathVariable Long id, @RequestBody Criterion criterion) { + return Result.success("Criterion updated.", rubricService.updateCriterion(id, criterion)); + } + + record CriterionSearchRequest(Long criterionId, String criterion) {} +} diff --git a/src/backend/src/main/java/team/projectpulse/rubric/CriterionRepository.java b/src/backend/src/main/java/team/projectpulse/rubric/CriterionRepository.java new file mode 100644 index 0000000..38f8314 --- /dev/null +++ b/src/backend/src/main/java/team/projectpulse/rubric/CriterionRepository.java @@ -0,0 +1,21 @@ +package team.projectpulse.rubric; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface CriterionRepository extends JpaRepository { + + @Query(""" + SELECT c FROM Criterion c + WHERE (:criterionId IS NULL OR c.criterionId = :criterionId) + AND (:criterion IS NULL OR LOWER(c.criterion) LIKE LOWER(CONCAT('%', :criterion, '%'))) + ORDER BY c.criterion ASC + """) + List searchByCriteria( + @Param("criterionId") Long criterionId, + @Param("criterion") String criterion + ); +} diff --git a/src/backend/src/main/java/team/projectpulse/rubric/Rubric.java b/src/backend/src/main/java/team/projectpulse/rubric/Rubric.java new file mode 100644 index 0000000..3084145 --- /dev/null +++ b/src/backend/src/main/java/team/projectpulse/rubric/Rubric.java @@ -0,0 +1,44 @@ +package team.projectpulse.rubric; + +import jakarta.persistence.*; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table(name = "rubrics") +public class Rubric { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "rubric_id") + private Long rubricId; + + @Column(name = "rubric_name", nullable = false) + private String rubricName; + + @Column(name = "course_id") + private Long courseId; + + @ManyToMany(fetch = FetchType.EAGER) + @JoinTable( + name = "rubric_criteria", + joinColumns = @JoinColumn(name = "rubric_id"), + inverseJoinColumns = @JoinColumn(name = "criterion_id") + ) + private List criteria = new ArrayList<>(); + + // ── Getters / Setters ──────────────────────────────────────────────────── + + public Long getRubricId() { return rubricId; } + public void setRubricId(Long v) { this.rubricId = v; } + + public String getRubricName() { return rubricName; } + public void setRubricName(String v) { this.rubricName = v; } + + public Long getCourseId() { return courseId; } + public void setCourseId(Long v) { this.courseId = v; } + + public List getCriteria() { return criteria; } + public void setCriteria(List v) { this.criteria = v; } +} diff --git a/src/backend/src/main/java/team/projectpulse/rubric/RubricController.java b/src/backend/src/main/java/team/projectpulse/rubric/RubricController.java index 3b5f646..313f5be 100644 --- a/src/backend/src/main/java/team/projectpulse/rubric/RubricController.java +++ b/src/backend/src/main/java/team/projectpulse/rubric/RubricController.java @@ -1,16 +1,57 @@ package team.projectpulse.rubric; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -/** - * Handles Rubric and Criteria management endpoints. - * - * Use Cases: - * UC-1: Admin creates a rubric - */ +import org.springframework.web.bind.annotation.*; +import team.projectpulse.common.Result; + +import java.util.List; +import java.util.Map; + @RestController @RequestMapping("${api.endpoint.base-url}/rubrics") public class RubricController { - // TODO: Implement UC-1 + + private final RubricService rubricService; + + public RubricController(RubricService rubricService) { + this.rubricService = rubricService; + } + + /** POST /rubrics/search */ + @PostMapping("/search") + public Result> search(@RequestBody RubricSearchRequest body) { + List list = rubricService.searchRubrics(body.rubricId(), body.rubricName()); + return Result.success(Map.of("content", list)); + } + + /** GET /rubrics/{id} */ + @GetMapping("/{id}") + public Result findById(@PathVariable Long id) { + return Result.success(rubricService.findRubricById(id)); + } + + /** POST /rubrics */ + @PostMapping + public Result create(@RequestBody Rubric rubric) { + return Result.success("Rubric created.", rubricService.createRubric(rubric)); + } + + /** PUT /rubrics/{id} */ + @PutMapping("/{id}") + public Result update(@PathVariable Long id, @RequestBody Rubric rubric) { + return Result.success("Rubric updated.", rubricService.updateRubric(id, rubric)); + } + + /** PUT /rubrics/{rubricId}/criteria/{criterionId} */ + @PutMapping("/{rubricId}/criteria/{criterionId}") + public Result assignCriterion(@PathVariable Long rubricId, @PathVariable Long criterionId) { + return Result.success("Criterion assigned.", rubricService.assignCriterion(rubricId, criterionId)); + } + + /** DELETE /rubrics/{rubricId}/criteria/{criterionId} */ + @DeleteMapping("/{rubricId}/criteria/{criterionId}") + public Result unassignCriterion(@PathVariable Long rubricId, @PathVariable Long criterionId) { + return Result.success("Criterion removed.", rubricService.unassignCriterion(rubricId, criterionId)); + } + + record RubricSearchRequest(Long rubricId, String rubricName) {} } diff --git a/src/backend/src/main/java/team/projectpulse/rubric/RubricExceptionHandler.java b/src/backend/src/main/java/team/projectpulse/rubric/RubricExceptionHandler.java new file mode 100644 index 0000000..ecdcd95 --- /dev/null +++ b/src/backend/src/main/java/team/projectpulse/rubric/RubricExceptionHandler.java @@ -0,0 +1,17 @@ +package team.projectpulse.rubric; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import team.projectpulse.common.Result; + +@RestControllerAdvice +public class RubricExceptionHandler { + + @ExceptionHandler(RubricNotFoundException.class) + @ResponseStatus(HttpStatus.NOT_FOUND) + Result handleNotFound(RubricNotFoundException ex) { + return Result.notFound(ex.getMessage()); + } +} diff --git a/src/backend/src/main/java/team/projectpulse/rubric/RubricNotFoundException.java b/src/backend/src/main/java/team/projectpulse/rubric/RubricNotFoundException.java new file mode 100644 index 0000000..e9374eb --- /dev/null +++ b/src/backend/src/main/java/team/projectpulse/rubric/RubricNotFoundException.java @@ -0,0 +1,7 @@ +package team.projectpulse.rubric; + +public class RubricNotFoundException extends RuntimeException { + public RubricNotFoundException(String type, Long id) { + super("Could not find " + type + " with id: " + id); + } +} diff --git a/src/backend/src/main/java/team/projectpulse/rubric/RubricRepository.java b/src/backend/src/main/java/team/projectpulse/rubric/RubricRepository.java new file mode 100644 index 0000000..972659a --- /dev/null +++ b/src/backend/src/main/java/team/projectpulse/rubric/RubricRepository.java @@ -0,0 +1,21 @@ +package team.projectpulse.rubric; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface RubricRepository extends JpaRepository { + + @Query(""" + SELECT r FROM Rubric r + WHERE (:rubricId IS NULL OR r.rubricId = :rubricId) + AND (:rubricName IS NULL OR LOWER(r.rubricName) LIKE LOWER(CONCAT('%', :rubricName, '%'))) + ORDER BY r.rubricName ASC + """) + List searchByCriteria( + @Param("rubricId") Long rubricId, + @Param("rubricName") String rubricName + ); +} diff --git a/src/backend/src/main/java/team/projectpulse/rubric/RubricService.java b/src/backend/src/main/java/team/projectpulse/rubric/RubricService.java new file mode 100644 index 0000000..9c1315a --- /dev/null +++ b/src/backend/src/main/java/team/projectpulse/rubric/RubricService.java @@ -0,0 +1,84 @@ +package team.projectpulse.rubric; + +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class RubricService { + + private final RubricRepository rubricRepository; + private final CriterionRepository criterionRepository; + + public RubricService(RubricRepository rubricRepository, CriterionRepository criterionRepository) { + this.rubricRepository = rubricRepository; + this.criterionRepository = criterionRepository; + } + + // ── Criteria ───────────────────────────────────────────────────────────── + + public List searchCriteria(Long criterionId, String criterion) { + String name = (criterion == null || criterion.isBlank()) ? null : criterion.trim(); + return criterionRepository.searchByCriteria(criterionId, name); + } + + public Criterion findCriterionById(Long id) { + return criterionRepository.findById(id) + .orElseThrow(() -> new RubricNotFoundException("criterion", id)); + } + + public Criterion createCriterion(Criterion c) { + c.setCriterionId(null); + return criterionRepository.save(c); + } + + public Criterion updateCriterion(Long id, Criterion c) { + Criterion existing = findCriterionById(id); + existing.setCriterion(c.getCriterion()); + existing.setDescription(c.getDescription()); + existing.setMaxScore(c.getMaxScore()); + existing.setCourseId(c.getCourseId()); + return criterionRepository.save(existing); + } + + // ── Rubrics ─────────────────────────────────────────────────────────────── + + public List searchRubrics(Long rubricId, String rubricName) { + String name = (rubricName == null || rubricName.isBlank()) ? null : rubricName.trim(); + return rubricRepository.searchByCriteria(rubricId, name); + } + + public Rubric findRubricById(Long id) { + return rubricRepository.findById(id) + .orElseThrow(() -> new RubricNotFoundException("rubric", id)); + } + + public Rubric createRubric(Rubric r) { + r.setRubricId(null); + r.setCriteria(List.of()); + return rubricRepository.save(r); + } + + public Rubric updateRubric(Long id, Rubric r) { + Rubric existing = findRubricById(id); + existing.setRubricName(r.getRubricName()); + existing.setCourseId(r.getCourseId()); + return rubricRepository.save(existing); + } + + public Rubric assignCriterion(Long rubricId, Long criterionId) { + Rubric rubric = findRubricById(rubricId); + Criterion criterion = findCriterionById(criterionId); + if (!rubric.getCriteria().contains(criterion)) { + rubric.getCriteria().add(criterion); + rubricRepository.save(rubric); + } + return rubric; + } + + public Rubric unassignCriterion(Long rubricId, Long criterionId) { + Rubric rubric = findRubricById(rubricId); + rubric.getCriteria().removeIf(c -> c.getCriterionId().equals(criterionId)); + return rubricRepository.save(rubric); + } +} diff --git a/src/backend/src/main/resources/db/migration/V3__rubrics.sql b/src/backend/src/main/resources/db/migration/V3__rubrics.sql new file mode 100644 index 0000000..ae53879 --- /dev/null +++ b/src/backend/src/main/resources/db/migration/V3__rubrics.sql @@ -0,0 +1,41 @@ +-- V3: Rubrics and Criteria schema + +CREATE TABLE IF NOT EXISTS criteria ( + criterion_id BIGINT AUTO_INCREMENT PRIMARY KEY, + criterion VARCHAR(255) NOT NULL, + description TEXT, + max_score INT NOT NULL DEFAULT 10, + course_id BIGINT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS rubrics ( + rubric_id BIGINT AUTO_INCREMENT PRIMARY KEY, + rubric_name VARCHAR(255) NOT NULL, + course_id BIGINT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS rubric_criteria ( + rubric_id BIGINT NOT NULL, + criterion_id BIGINT NOT NULL, + PRIMARY KEY (rubric_id, criterion_id), + FOREIGN KEY (rubric_id) REFERENCES rubrics(rubric_id) ON DELETE CASCADE, + FOREIGN KEY (criterion_id) REFERENCES criteria(criterion_id) ON DELETE CASCADE +); + +-- Seed criteria +INSERT INTO criteria (criterion, description, max_score) VALUES + ('Participation', 'Active engagement in team meetings and project activities.', 10), + ('Code Quality', 'Adherence to coding standards, readability, and documentation.', 10), + ('Communication', 'Clarity and frequency of communication with teammates.', 10), + ('Deliverables', 'Timely completion of assigned tasks and milestones.', 10), + ('Problem Solving', 'Ability to identify and resolve technical or team challenges.', 10); + +-- Seed a default rubric with all criteria assigned +INSERT INTO rubrics (rubric_name) VALUES ('Default Peer Evaluation Rubric'); + +INSERT INTO rubric_criteria (rubric_id, criterion_id) +SELECT r.rubric_id, c.criterion_id +FROM rubrics r, criteria c +WHERE r.rubric_name = 'Default Peer Evaluation Rubric'; diff --git a/src/frontend/src/pages/rubrics/Criteria.vue b/src/frontend/src/pages/rubrics/Criteria.vue index 414f679..9e7b10d 100644 --- a/src/frontend/src/pages/rubrics/Criteria.vue +++ b/src/frontend/src/pages/rubrics/Criteria.vue @@ -1,17 +1,173 @@ - + diff --git a/src/frontend/src/pages/rubrics/Rubrics.vue b/src/frontend/src/pages/rubrics/Rubrics.vue index ea8bcca..823a9bf 100644 --- a/src/frontend/src/pages/rubrics/Rubrics.vue +++ b/src/frontend/src/pages/rubrics/Rubrics.vue @@ -1,17 +1,283 @@ - +