diff --git a/package-lock.json b/package-lock.json index e324a36..bea1903 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2439,32 +2439,6 @@ "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -2565,9 +2539,9 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -2611,22 +2585,25 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", - "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", + "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } }, "node_modules/baseline-browser-mapping": { "version": "2.9.15", @@ -2639,14 +2616,16 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", + "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/browserslist": { @@ -2797,13 +2776,6 @@ "node": ">= 0.8" } }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -4051,16 +4023,19 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz", + "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^1.1.7" + "brace-expansion": "^5.0.2" }, "engines": { - "node": "*" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/motion": { diff --git a/package.json b/package.json index 145902f..f62c55d 100644 --- a/package.json +++ b/package.json @@ -49,5 +49,9 @@ "typescript": "5.9.3", "typescript-eslint": "^8.20.0", "vite": "^6.0.7" + }, + "overrides": { + "ajv": "^6.14.0", + "minimatch": "^10.2.2" } } diff --git a/server/src/main/java/de/tum/cit/memo/controller/CompetencyRelationshipController.java b/server/src/main/java/de/tum/cit/memo/controller/CompetencyRelationshipController.java index d0efa69..d7dc6c2 100644 --- a/server/src/main/java/de/tum/cit/memo/controller/CompetencyRelationshipController.java +++ b/server/src/main/java/de/tum/cit/memo/controller/CompetencyRelationshipController.java @@ -1,24 +1,23 @@ package de.tum.cit.memo.controller; -import de.tum.cit.memo.dto.CreateCompetencyRelationshipRequest; import de.tum.cit.memo.entity.CompetencyRelationship; import de.tum.cit.memo.service.CompetencyRelationshipService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.util.List; +/** + * CRUD endpoints for competency relationships (voting happens via + * SchedulingController). + */ @RestController @RequestMapping("/api/competency-relationships") @RequiredArgsConstructor @@ -27,13 +26,6 @@ public class CompetencyRelationshipController { private final CompetencyRelationshipService relationshipService; - @PostMapping - @Operation(summary = "Create a new competency relationship") - public ResponseEntity createRelationship(@Valid @RequestBody CreateCompetencyRelationshipRequest request) { - CompetencyRelationship relationship = relationshipService.createRelationship(request); - return ResponseEntity.status(HttpStatus.CREATED).body(relationship); - } - @GetMapping("/{id}") @Operation(summary = "Get competency relationship by ID") public ResponseEntity getRelationshipById(@PathVariable String id) { diff --git a/server/src/main/java/de/tum/cit/memo/controller/SchedulingController.java b/server/src/main/java/de/tum/cit/memo/controller/SchedulingController.java new file mode 100644 index 0000000..9558b31 --- /dev/null +++ b/server/src/main/java/de/tum/cit/memo/controller/SchedulingController.java @@ -0,0 +1,44 @@ +package de.tum.cit.memo.controller; + +import de.tum.cit.memo.dto.RelationshipTaskResponse; +import de.tum.cit.memo.dto.VoteRequest; +import de.tum.cit.memo.dto.VoteResponse; +import de.tum.cit.memo.service.SchedulingService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/scheduling") +@RequiredArgsConstructor +@Tag(name = "Scheduling", description = "Endpoints for competency mapping scheduling") +public class SchedulingController { + + private final SchedulingService schedulingService; + + @GetMapping("/next-relationship") + @Operation(summary = "Get next relationship to vote on", description = "Returns the next competency pair for this user. 204 if no tasks remain.") + public ResponseEntity getNextRelationship( + @RequestHeader("X-User-Id") String userId) { + return schedulingService.getNextTask(userId) + .map(ResponseEntity::ok) + .orElse(ResponseEntity.noContent().build()); + } + + @PostMapping("/vote") + @Operation(summary = "Submit a vote on a relationship", description = "Records the user's vote on a competency pair identified by originId + destinationId.") + public ResponseEntity submitVote( + @RequestHeader("X-User-Id") String userId, + @Valid @RequestBody VoteRequest request) { + VoteResponse response = schedulingService.submitVote(userId, request); + return ResponseEntity.ok(response); + } +} diff --git a/server/src/main/java/de/tum/cit/memo/dto/RelationshipTaskResponse.java b/server/src/main/java/de/tum/cit/memo/dto/RelationshipTaskResponse.java new file mode 100644 index 0000000..ba78b5a --- /dev/null +++ b/server/src/main/java/de/tum/cit/memo/dto/RelationshipTaskResponse.java @@ -0,0 +1,29 @@ +package de.tum.cit.memo.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class RelationshipTaskResponse { + + private String relationshipId; + private CompetencyInfo origin; + private CompetencyInfo destination; + private String pipeline; + private VoteCounts currentVotes; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class CompetencyInfo { + private String id; + private String title; + private String description; + } +} diff --git a/server/src/main/java/de/tum/cit/memo/dto/VoteCounts.java b/server/src/main/java/de/tum/cit/memo/dto/VoteCounts.java new file mode 100644 index 0000000..a2fd05b --- /dev/null +++ b/server/src/main/java/de/tum/cit/memo/dto/VoteCounts.java @@ -0,0 +1,18 @@ +package de.tum.cit.memo.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class VoteCounts { + + private int assumes; + private int extendsRelation; + private int matches; + private int unrelated; +} diff --git a/server/src/main/java/de/tum/cit/memo/dto/CreateCompetencyRelationshipRequest.java b/server/src/main/java/de/tum/cit/memo/dto/VoteRequest.java similarity index 54% rename from server/src/main/java/de/tum/cit/memo/dto/CreateCompetencyRelationshipRequest.java rename to server/src/main/java/de/tum/cit/memo/dto/VoteRequest.java index 8f2a5a2..5915e66 100644 --- a/server/src/main/java/de/tum/cit/memo/dto/CreateCompetencyRelationshipRequest.java +++ b/server/src/main/java/de/tum/cit/memo/dto/VoteRequest.java @@ -1,7 +1,6 @@ package de.tum.cit.memo.dto; import de.tum.cit.memo.enums.RelationshipType; -import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import lombok.AllArgsConstructor; import lombok.Builder; @@ -12,17 +11,14 @@ @Builder @NoArgsConstructor @AllArgsConstructor -public class CreateCompetencyRelationshipRequest { +public class VoteRequest { - @NotNull(message = "Relationship type is required") - private RelationshipType relationshipType; - - @NotBlank(message = "Origin ID is required") + @NotNull private String originId; - @NotBlank(message = "Destination ID is required") + @NotNull private String destinationId; - @NotBlank(message = "User ID is required") - private String userId; + @NotNull + private RelationshipType relationshipType; } diff --git a/server/src/main/java/de/tum/cit/memo/dto/VoteResponse.java b/server/src/main/java/de/tum/cit/memo/dto/VoteResponse.java new file mode 100644 index 0000000..7d3847a --- /dev/null +++ b/server/src/main/java/de/tum/cit/memo/dto/VoteResponse.java @@ -0,0 +1,18 @@ +package de.tum.cit.memo.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class VoteResponse { + + private String relationshipId; + private boolean success; + private VoteCounts updatedVotes; + private double newEntropy; +} diff --git a/server/src/main/java/de/tum/cit/memo/entity/Competency.java b/server/src/main/java/de/tum/cit/memo/entity/Competency.java index d869f10..087febb 100644 --- a/server/src/main/java/de/tum/cit/memo/entity/Competency.java +++ b/server/src/main/java/de/tum/cit/memo/entity/Competency.java @@ -35,4 +35,7 @@ public class Competency { @CreationTimestamp @Column(nullable = false, updatable = false) private Instant createdAt; + + @Column(nullable = false) + private int degree; } diff --git a/server/src/main/java/de/tum/cit/memo/entity/CompetencyRelationship.java b/server/src/main/java/de/tum/cit/memo/entity/CompetencyRelationship.java index 27b9982..ff7b2da 100644 --- a/server/src/main/java/de/tum/cit/memo/entity/CompetencyRelationship.java +++ b/server/src/main/java/de/tum/cit/memo/entity/CompetencyRelationship.java @@ -1,17 +1,14 @@ package de.tum.cit.memo.entity; -import de.tum.cit.memo.enums.RelationshipType; +import com.fasterxml.jackson.annotation.JsonIgnore; import jakarta.persistence.Column; import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; import jakarta.persistence.FetchType; import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -19,6 +16,7 @@ import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.OnDelete; import org.hibernate.annotations.OnDeleteAction; +import org.hibernate.annotations.UpdateTimestamp; import java.time.Instant; @@ -34,11 +32,6 @@ public class CompetencyRelationship { @Column(length = 30) private String id; - @Enumerated(EnumType.STRING) - @Column(name = "relationship_type", nullable = false) - @NotNull - private RelationshipType relationshipType; - @NotBlank @Column(name = "origin_id", nullable = false, length = 30) private String originId; @@ -47,25 +40,66 @@ public class CompetencyRelationship { @Column(name = "destination_id", nullable = false, length = 30) private String destinationId; - @NotBlank - @Column(name = "user_id", nullable = false, length = 30) - private String userId; + // Aggregated vote counters + @Column(name = "vote_assumes", nullable = false) + private int voteAssumes; + + @Column(name = "vote_extends", nullable = false) + private int voteExtends; + + @Column(name = "vote_matches", nullable = false) + private int voteMatches; + + @Column(name = "vote_unrelated", nullable = false) + private int voteUnrelated; + + @Column(name = "entropy", nullable = false) + private double entropy; + + @Column(name = "total_votes", nullable = false) + private int totalVotes; + @JsonIgnore @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "origin_id", insertable = false, updatable = false) @OnDelete(action = OnDeleteAction.CASCADE) private Competency origin; + @JsonIgnore @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "destination_id", insertable = false, updatable = false) @OnDelete(action = OnDeleteAction.CASCADE) private Competency destination; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id", insertable = false, updatable = false) - private User user; - @CreationTimestamp @Column(nullable = false, updatable = false) private Instant createdAt; + + @UpdateTimestamp + @Column(nullable = false) + private Instant updatedAt; + + /** + * Recomputes Shannon entropy from the current vote counts. + * Entropy ranges from 0 (unanimous) to ~2.0 (max disagreement with 4 types). + */ + public void recalculateEntropy() { + int total = voteAssumes + voteExtends + voteMatches + voteUnrelated; + this.totalVotes = total; + + if (total == 0) { + this.entropy = 0.0; + return; + } + + double ent = 0.0; + int[] counts = { voteAssumes, voteExtends, voteMatches, voteUnrelated }; + for (int count : counts) { + if (count > 0) { + double p = (double) count / total; + ent -= p * (Math.log(p) / Math.log(2)); + } + } + this.entropy = ent; + } } diff --git a/server/src/main/java/de/tum/cit/memo/entity/CompetencyRelationshipVote.java b/server/src/main/java/de/tum/cit/memo/entity/CompetencyRelationshipVote.java new file mode 100644 index 0000000..91af636 --- /dev/null +++ b/server/src/main/java/de/tum/cit/memo/entity/CompetencyRelationshipVote.java @@ -0,0 +1,63 @@ +package de.tum.cit.memo.entity; + +import de.tum.cit.memo.enums.RelationshipType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; + +import java.time.Instant; + +@Entity +@Table(name = "competency_relationships_votes") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CompetencyRelationshipVote { + + @Id + @Column(length = 30) + private String id; + + @NotBlank + @Column(name = "relationship_id", nullable = false, length = 30) + private String relationshipId; + + @NotBlank + @Column(name = "user_id", nullable = false, length = 30) + private String userId; + + @Enumerated(EnumType.STRING) + @Column(name = "relationship_type", nullable = false) + @NotNull + private RelationshipType relationshipType; + + // Relationships + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "relationship_id", insertable = false, updatable = false) + @OnDelete(action = OnDeleteAction.CASCADE) + private CompetencyRelationship relationship; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", insertable = false, updatable = false) + private User user; + + @CreationTimestamp + @Column(nullable = false, updatable = false) + private Instant createdAt; +} diff --git a/server/src/main/java/de/tum/cit/memo/repository/CompetencyRelationshipRepository.java b/server/src/main/java/de/tum/cit/memo/repository/CompetencyRelationshipRepository.java index 520a0ce..2998fa4 100644 --- a/server/src/main/java/de/tum/cit/memo/repository/CompetencyRelationshipRepository.java +++ b/server/src/main/java/de/tum/cit/memo/repository/CompetencyRelationshipRepository.java @@ -1,24 +1,51 @@ package de.tum.cit.memo.repository; import de.tum.cit.memo.entity.CompetencyRelationship; -import de.tum.cit.memo.enums.RelationshipType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import java.util.List; import java.util.Optional; @Repository public interface CompetencyRelationshipRepository extends JpaRepository { - Optional findByOriginIdAndDestinationIdAndRelationshipType( - String originId, - String destinationId, - RelationshipType relationshipType - ); - - boolean existsByOriginIdAndDestinationIdAndRelationshipType( - String originId, - String destinationId, - RelationshipType relationshipType - ); + Optional findByOriginIdAndDestinationId(String originId, String destinationId); + + boolean existsByOriginIdAndDestinationId(String originId, String destinationId); + + /** High-entropy relationships this user hasn't voted on yet. */ + @Query(""" + SELECT r FROM CompetencyRelationship r + WHERE r.totalVotes BETWEEN :minVotes AND :maxVotes + AND r.entropy > :minEntropy + AND NOT EXISTS ( + SELECT 1 FROM CompetencyRelationshipVote v + WHERE v.relationshipId = r.id AND v.userId = :userId + ) + ORDER BY r.entropy DESC + """) + List findHighEntropyRelationshipsExcludingUser( + @Param("userId") String userId, + @Param("minVotes") int minVotes, + @Param("maxVotes") int maxVotes, + @Param("minEntropy") double minEntropy, + org.springframework.data.domain.Pageable pageable); + + /** All relationships where both endpoints are within the given ID pool. */ + @Query("SELECT r FROM CompetencyRelationship r WHERE r.originId IN :ids AND r.destinationId IN :ids") + List findIntraPoolRelationships(@Param("ids") List ids); + + /** Relationships the user hasn't voted on yet. */ + @Query(""" + SELECT r FROM CompetencyRelationship r + WHERE NOT EXISTS ( + SELECT 1 FROM CompetencyRelationshipVote v + WHERE v.relationshipId = r.id AND v.userId = :userId + ) + """) + List findUnvotedByUser(@Param("userId") String userId, + org.springframework.data.domain.Pageable pageable); } diff --git a/server/src/main/java/de/tum/cit/memo/repository/CompetencyRelationshipVoteRepository.java b/server/src/main/java/de/tum/cit/memo/repository/CompetencyRelationshipVoteRepository.java new file mode 100644 index 0000000..014bd02 --- /dev/null +++ b/server/src/main/java/de/tum/cit/memo/repository/CompetencyRelationshipVoteRepository.java @@ -0,0 +1,11 @@ +package de.tum.cit.memo.repository; + +import de.tum.cit.memo.entity.CompetencyRelationshipVote; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface CompetencyRelationshipVoteRepository extends JpaRepository { + + boolean existsByRelationshipIdAndUserId(String relationshipId, String userId); +} diff --git a/server/src/main/java/de/tum/cit/memo/repository/CompetencyRepository.java b/server/src/main/java/de/tum/cit/memo/repository/CompetencyRepository.java index 50e70da..fc80203 100644 --- a/server/src/main/java/de/tum/cit/memo/repository/CompetencyRepository.java +++ b/server/src/main/java/de/tum/cit/memo/repository/CompetencyRepository.java @@ -13,4 +13,18 @@ public interface CompetencyRepository extends JpaRepository @Query(value = "SELECT * FROM competencies ORDER BY RANDOM() LIMIT :count", nativeQuery = true) List findRandomCompetencies(@Param("count") int count); + + @Query(value = "SELECT id FROM competencies ORDER BY RANDOM() LIMIT :count", nativeQuery = true) + List findRandomCompetencyIds(@Param("count") int count); + + @Query("SELECT c.id FROM Competency c ORDER BY c.degree ASC") + List findIdsByDegreeAsc(org.springframework.data.domain.Pageable pageable); + + @org.springframework.data.jpa.repository.Modifying(flushAutomatically = true, clearAutomatically = true) + @Query("UPDATE Competency c SET c.degree = c.degree + 1 WHERE c.id IN :ids") + void incrementDegree(@Param("ids") List ids); + + @org.springframework.data.jpa.repository.Modifying(flushAutomatically = true, clearAutomatically = true) + @Query(value = "UPDATE competencies SET degree = GREATEST(degree - 1, 0) WHERE id IN :ids", nativeQuery = true) + void decrementDegree(@Param("ids") List ids); } diff --git a/server/src/main/java/de/tum/cit/memo/service/CompetencyRelationshipService.java b/server/src/main/java/de/tum/cit/memo/service/CompetencyRelationshipService.java index 055465d..f097c11 100644 --- a/server/src/main/java/de/tum/cit/memo/service/CompetencyRelationshipService.java +++ b/server/src/main/java/de/tum/cit/memo/service/CompetencyRelationshipService.java @@ -1,10 +1,10 @@ package de.tum.cit.memo.service; -import de.tum.cit.memo.dto.CreateCompetencyRelationshipRequest; import de.tum.cit.memo.entity.CompetencyRelationship; import de.tum.cit.memo.exception.InvalidOperationException; import de.tum.cit.memo.exception.ResourceNotFoundException; import de.tum.cit.memo.repository.CompetencyRelationshipRepository; +import de.tum.cit.memo.repository.CompetencyRepository; import de.tum.cit.memo.util.IdGenerator; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -12,34 +12,32 @@ import java.util.List; +/** + * CRUD for competency relationships (voting logic lives in SchedulingService). + */ @Service @RequiredArgsConstructor @SuppressWarnings("null") public class CompetencyRelationshipService { private final CompetencyRelationshipRepository relationshipRepository; + private final CompetencyRepository competencyRepository; @Transactional - public CompetencyRelationship createRelationship(CreateCompetencyRelationshipRequest request) { - if (request.getOriginId().equals(request.getDestinationId())) { - throw new InvalidOperationException("Cannot create relationship to itself"); - } + public CompetencyRelationship createRelationship(String originId, String destinationId) { + validateDistinctIds(originId, destinationId); - CompetencyRelationship relationship = CompetencyRelationship.builder() - .id(IdGenerator.generateCuid()) - .relationshipType(request.getRelationshipType()) - .originId(request.getOriginId()) - .destinationId(request.getDestinationId()) - .userId(request.getUserId()) - .build(); + if (relationshipRepository.existsByOriginIdAndDestinationId(originId, destinationId)) { + throw new InvalidOperationException("Relationship already exists"); + } - return relationshipRepository.save(relationship); + return buildAndSave(originId, destinationId); } @Transactional(readOnly = true) public CompetencyRelationship getRelationshipById(String id) { return relationshipRepository.findById(id) - .orElseThrow(() -> new ResourceNotFoundException("Relationship not found")); + .orElseThrow(() -> new ResourceNotFoundException("Relationship not found")); } @Transactional(readOnly = true) @@ -49,9 +47,27 @@ public List getAllRelationships() { @Transactional public void deleteRelationship(String id) { - if (!relationshipRepository.existsById(id)) { - throw new ResourceNotFoundException("Relationship not found"); - } + CompetencyRelationship relationship = relationshipRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("Relationship not found")); + + competencyRepository.decrementDegree(List.of(relationship.getOriginId(), relationship.getDestinationId())); relationshipRepository.deleteById(id); } + + private CompetencyRelationship buildAndSave(String originId, String destinationId) { + CompetencyRelationship relationship = CompetencyRelationship.builder() + .id(IdGenerator.generateCuid()) + .originId(originId) + .destinationId(destinationId) + .build(); + + competencyRepository.incrementDegree(List.of(originId, destinationId)); + return relationshipRepository.save(relationship); + } + + private static void validateDistinctIds(String originId, String destinationId) { + if (originId.equals(destinationId)) { + throw new InvalidOperationException("Cannot create relationship to itself"); + } + } } diff --git a/server/src/main/java/de/tum/cit/memo/service/SchedulingService.java b/server/src/main/java/de/tum/cit/memo/service/SchedulingService.java new file mode 100644 index 0000000..6163c00 --- /dev/null +++ b/server/src/main/java/de/tum/cit/memo/service/SchedulingService.java @@ -0,0 +1,287 @@ +package de.tum.cit.memo.service; + +import de.tum.cit.memo.dto.RelationshipTaskResponse; +import de.tum.cit.memo.dto.VoteCounts; +import de.tum.cit.memo.dto.VoteRequest; +import de.tum.cit.memo.dto.VoteResponse; +import de.tum.cit.memo.entity.Competency; +import de.tum.cit.memo.entity.CompetencyRelationship; +import de.tum.cit.memo.entity.CompetencyRelationshipVote; +import de.tum.cit.memo.enums.RelationshipType; +import de.tum.cit.memo.exception.ResourceNotFoundException; +import de.tum.cit.memo.repository.CompetencyRelationshipRepository; +import de.tum.cit.memo.repository.CompetencyRelationshipVoteRepository; +import de.tum.cit.memo.repository.CompetencyRepository; +import de.tum.cit.memo.util.IdGenerator; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Random; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * Handles scheduling of competency mapping tasks using two pipelines: + * - Coverage (70%): pairs up low-degree competencies to grow the graph + * - Consensus (30%): re-surfaces ambiguous relationships for extra votes + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class SchedulingService { + + private static final double COVERAGE_WEIGHT = 0.7; + private static final int LOW_DEGREE_POOL_SIZE = 20; + private static final int CONSENSUS_CANDIDATE_LIMIT = 20; + private static final int CONSENSUS_MIN_VOTES = 5; + private static final int CONSENSUS_MAX_VOTES = 20; + private static final double CONSENSUS_MIN_ENTROPY = 0.5; + + private final CompetencyRelationshipRepository relationshipRepository; + private final CompetencyRelationshipVoteRepository voteRepository; + private final CompetencyRepository competencyRepository; + private final Random random = new Random(); + + /** Returns the next pair for a user to vote on, or empty if none left. */ + @Transactional + public Optional getNextTask(String userId) { + if (random.nextDouble() < COVERAGE_WEIGHT) { + log.debug("Coverage pipeline for user {}", userId); + return coveragePipeline(userId); + } + + log.debug("Consensus pipeline for user {}", userId); + RelationshipTaskResponse task = consensusPipeline(userId); + if (task != null) { + return Optional.of(task); + } + + log.debug("No consensus candidates, falling back to coverage"); + return coveragePipeline(userId); + } + + @Transactional + public VoteResponse submitVote(String userId, VoteRequest request) { + // Look up (or create) the relationship for this pair + CompetencyRelationship rel = relationshipRepository + .findByOriginIdAndDestinationId(request.getOriginId(), request.getDestinationId()) + .orElseGet(() -> createRelationship(request.getOriginId(), request.getDestinationId())); + + if (voteRepository.existsByRelationshipIdAndUserId(rel.getId(), userId)) { + log.debug("User {} already voted on {}", userId, rel.getId()); + return toVoteResponse(rel); + } + + saveVote(rel.getId(), userId, request.getRelationshipType()); + applyVote(rel, request.getRelationshipType()); + + // MATCHES and UNRELATED are symmetric, so mirror the vote to B→A + RelationshipType type = request.getRelationshipType(); + if (type == RelationshipType.MATCHES || type == RelationshipType.UNRELATED) { + mirrorSymmetricVote(rel, userId, type); + } + + return toVoteResponse(rel); + } + + private Optional coveragePipeline(String userId) { + List poolIds = getLowDegreeCompetencyIds(); + if (poolIds.size() < 2) { + log.debug("Not enough competencies to form pairs"); + return Optional.empty(); + } + + Set existingPairs = relationshipRepository.findIntraPoolRelationships(poolIds).stream() + .flatMap(r -> Stream.of(pairKey(r.getOriginId(), r.getDestinationId()), + pairKey(r.getDestinationId(), r.getOriginId()))) + .collect(Collectors.toSet()); + + List pool = new ArrayList<>(poolIds); + Collections.shuffle(pool, random); + + for (int i = 0; i < pool.size(); i++) { + for (int j = i + 1; j < pool.size(); j++) { + if (!existingPairs.contains(pairKey(pool.get(i), pool.get(j)))) { + return Optional.of(toTaskResponseFromIds(pool.get(i), pool.get(j), "COVERAGE")); + } + } + } + + log.debug("Pool fully connected, finding any unvoted relationship"); + return relationshipRepository + .findUnvotedByUser(userId, PageRequest.of(0, 1)) + .stream().findFirst() + .map(rel -> toTaskResponse(rel, "COVERAGE")); + } + + private RelationshipTaskResponse consensusPipeline(String userId) { + List candidates = relationshipRepository + .findHighEntropyRelationshipsExcludingUser( + userId, CONSENSUS_MIN_VOTES, CONSENSUS_MAX_VOTES, CONSENSUS_MIN_ENTROPY, + PageRequest.of(0, CONSENSUS_CANDIDATE_LIMIT)); + + if (candidates.isEmpty()) { + return null; + } + + return toTaskResponse(pickWeightedByEntropy(candidates), "CONSENSUS"); + } + + private List getLowDegreeCompetencyIds() { + List ids = competencyRepository.findIdsByDegreeAsc(PageRequest.of(0, LOW_DEGREE_POOL_SIZE)); + if (ids.isEmpty()) { + return competencyRepository.findRandomCompetencyIds(LOW_DEGREE_POOL_SIZE); + } + return ids; + } + + private CompetencyRelationship createRelationship(String originId, String destId) { + CompetencyRelationship rel = CompetencyRelationship.builder() + .id(IdGenerator.generateCuid()) + .originId(originId) + .destinationId(destId) + .build(); + + competencyRepository.incrementDegree(List.of(originId, destId)); + return relationshipRepository.save(rel); + } + + private CompetencyRelationship pickWeightedByEntropy(List candidates) { + double totalWeight = candidates.stream() + .mapToDouble(this::entropyWeight) + .sum(); + double roll = random.nextDouble() * totalWeight; + double cumulative = 0; + + for (CompetencyRelationship rel : candidates) { + cumulative += entropyWeight(rel); + if (roll <= cumulative) { + return rel; + } + } + return candidates.get(0); + } + + private double entropyWeight(CompetencyRelationship rel) { + return rel.getEntropy() / (rel.getTotalVotes() + 1.0); + } + + private void saveVote(String relationshipId, String userId, RelationshipType type) { + voteRepository.save(CompetencyRelationshipVote.builder() + .id(IdGenerator.generateCuid()) + .relationshipId(relationshipId) + .userId(userId) + .relationshipType(type) + .build()); + } + + private void applyVote(CompetencyRelationship rel, RelationshipType type) { + switch (type) { + case ASSUMES -> rel.setVoteAssumes(rel.getVoteAssumes() + 1); + case EXTENDS -> rel.setVoteExtends(rel.getVoteExtends() + 1); + case MATCHES -> rel.setVoteMatches(rel.getVoteMatches() + 1); + case UNRELATED -> rel.setVoteUnrelated(rel.getVoteUnrelated() + 1); + } + rel.recalculateEntropy(); + relationshipRepository.save(rel); + } + + /** For symmetric types, also records the vote on the reverse direction. */ + private void mirrorSymmetricVote(CompetencyRelationship originalRel, String userId, RelationshipType type) { + Optional reverseOpt = relationshipRepository + .findByOriginIdAndDestinationId(originalRel.getDestinationId(), originalRel.getOriginId()); + + CompetencyRelationship reverse = reverseOpt.orElseGet( + () -> createRelationship(originalRel.getDestinationId(), originalRel.getOriginId())); + + if (!voteRepository.existsByRelationshipIdAndUserId(reverse.getId(), userId)) { + applyVote(reverse, type); + saveVote(reverse.getId(), userId, type); + } + } + + private RelationshipTaskResponse toTaskResponse(CompetencyRelationship rel, String pipeline) { + Map byId = competencyRepository + .findAllById(List.of(rel.getOriginId(), rel.getDestinationId())) + .stream().collect(Collectors.toMap(Competency::getId, Function.identity())); + + Competency origin = byId.get(rel.getOriginId()); + Competency destination = byId.get(rel.getDestinationId()); + if (origin == null || destination == null) { + throw new ResourceNotFoundException("Competency not found for relationship " + rel.getId()); + } + + return RelationshipTaskResponse.builder() + .relationshipId(rel.getId()) + .origin(toCompetencyInfo(origin)) + .destination(toCompetencyInfo(destination)) + .pipeline(pipeline) + .currentVotes(toVoteCounts(rel)) + .build(); + } + + /** + * Builds a task response from two competency IDs without persisting a + * relationship. + * The relationship row is only created when the user actually votes. + */ + private RelationshipTaskResponse toTaskResponseFromIds(String originId, String destinationId, String pipeline) { + Map byId = competencyRepository + .findAllById(List.of(originId, destinationId)) + .stream().collect(Collectors.toMap(Competency::getId, Function.identity())); + + Competency origin = byId.get(originId); + Competency destination = byId.get(destinationId); + if (origin == null || destination == null) { + throw new ResourceNotFoundException("Competency not found for IDs " + originId + ", " + destinationId); + } + + return RelationshipTaskResponse.builder() + .relationshipId(null) + .origin(toCompetencyInfo(origin)) + .destination(toCompetencyInfo(destination)) + .pipeline(pipeline) + .currentVotes(VoteCounts.builder().build()) + .build(); + } + + private VoteResponse toVoteResponse(CompetencyRelationship rel) { + return VoteResponse.builder() + .relationshipId(rel.getId()) + .success(true) + .updatedVotes(toVoteCounts(rel)) + .newEntropy(rel.getEntropy()) + .build(); + } + + private static RelationshipTaskResponse.CompetencyInfo toCompetencyInfo(Competency c) { + return RelationshipTaskResponse.CompetencyInfo.builder() + .id(c.getId()) + .title(c.getTitle()) + .description(c.getDescription()) + .build(); + } + + private static VoteCounts toVoteCounts(CompetencyRelationship rel) { + return VoteCounts.builder() + .assumes(rel.getVoteAssumes()) + .extendsRelation(rel.getVoteExtends()) + .matches(rel.getVoteMatches()) + .unrelated(rel.getVoteUnrelated()) + .build(); + } + + private static String pairKey(String a, String b) { + return a + ':' + b; + } +} diff --git a/server/src/main/resources/db/migration/V2__seed_sample_data.sql b/server/src/main/resources/db/migration/V2__seed_sample_data.sql index 2a68f84..96ce707 100644 --- a/server/src/main/resources/db/migration/V2__seed_sample_data.sql +++ b/server/src/main/resources/db/migration/V2__seed_sample_data.sql @@ -31,12 +31,6 @@ INSERT INTO learning_resources (id, title, url, created_at) VALUES ('res_types_001', 'On Understanding Types, Data Abstraction, and Polymorphism', 'https://dl.acm.org/doi/10.1145/6041.6042', CURRENT_TIMESTAMP) ON CONFLICT DO NOTHING; --- Seed competency relationships (examples) -INSERT INTO competency_relationships (id, relationship_type, origin_id, destination_id, user_id, created_at) VALUES -('rel_ds_fp_001', 'ASSUMES', 'comp_fp_001', 'comp_ds_001', 'user_demo_001', CURRENT_TIMESTAMP), -('rel_hof_fp_001', 'EXTENDS', 'comp_hof_001', 'comp_fp_001', 'user_demo_001', CURRENT_TIMESTAMP), -('rel_mod_poly_001', 'ASSUMES', 'comp_mod_001', 'comp_poly_001', 'user_demo_001', CURRENT_TIMESTAMP) -ON CONFLICT (origin_id, destination_id, relationship_type) DO NOTHING; -- Seed competency-resource links (examples) INSERT INTO competency_resource_links (id, competency_id, resource_id, user_id, created_at) VALUES diff --git a/server/src/main/resources/db/migration/V5__drop_old_relationships.sql b/server/src/main/resources/db/migration/V5__drop_old_relationships.sql new file mode 100644 index 0000000..3e8123f --- /dev/null +++ b/server/src/main/resources/db/migration/V5__drop_old_relationships.sql @@ -0,0 +1,10 @@ +-- Drop old competency_relationships table and related constraints +-- Platform is in development; existing votes can be discarded + +-- First drop the foreign key constraints +ALTER TABLE IF EXISTS "competency_relationships" DROP CONSTRAINT IF EXISTS "competency_relationships_originId_fkey"; +ALTER TABLE IF EXISTS "competency_relationships" DROP CONSTRAINT IF EXISTS "competency_relationships_destinationId_fkey"; +ALTER TABLE IF EXISTS "competency_relationships" DROP CONSTRAINT IF EXISTS "competency_relationships_userId_fkey"; + +-- Drop the table +DROP TABLE IF EXISTS "competency_relationships"; diff --git a/server/src/main/resources/db/migration/V6__create_scheduling_tables.sql b/server/src/main/resources/db/migration/V6__create_scheduling_tables.sql new file mode 100644 index 0000000..3d64a42 --- /dev/null +++ b/server/src/main/resources/db/migration/V6__create_scheduling_tables.sql @@ -0,0 +1,59 @@ +-- New competency_relationships table with aggregated vote counters + entropy +CREATE TABLE "competency_relationships" ( + "id" VARCHAR(30) NOT NULL, + "origin_id" VARCHAR(30) NOT NULL, + "destination_id" VARCHAR(30) NOT NULL, + + -- vote counters (denormalized for fast reads) + "vote_assumes" INT NOT NULL DEFAULT 0, + "vote_extends" INT NOT NULL DEFAULT 0, + "vote_matches" INT NOT NULL DEFAULT 0, + "vote_unrelated" INT NOT NULL DEFAULT 0, + + -- precomputed entropy for consensus scheduling + "entropy" DOUBLE PRECISION NOT NULL DEFAULT 0.0, + + -- Total votes = vote_assumes + vote_extends + vote_matches + vote_unrelated + "total_votes" INT NOT NULL DEFAULT 0, + + "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "competency_relationships_pkey" PRIMARY KEY ("id"), + CONSTRAINT "uk_relationship_pair" UNIQUE ("origin_id", "destination_id"), + CONSTRAINT "chk_origin_ne_dest" CHECK ("origin_id" <> "destination_id") +); + +-- individual votes (one per user per relationship) +CREATE TABLE "competency_relationships_votes" ( + "id" VARCHAR(30) NOT NULL, + "relationship_id" VARCHAR(30) NOT NULL, + "user_id" VARCHAR(30) NOT NULL, + "relationship_type" VARCHAR(20) NOT NULL, + "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "competency_relationships_votes_pkey" PRIMARY KEY ("id"), + CONSTRAINT "uk_relationship_user" UNIQUE ("relationship_id", "user_id") +); + +-- indexes for scheduling queries +CREATE INDEX "idx_rel_entropy" ON "competency_relationships"("entropy" DESC) WHERE "total_votes" > 0; +CREATE INDEX "idx_rel_total_votes" ON "competency_relationships"("total_votes"); +CREATE INDEX "idx_rel_origin" ON "competency_relationships"("origin_id"); +CREATE INDEX "idx_rel_dest" ON "competency_relationships"("destination_id"); + +CREATE INDEX "idx_votes_rel" ON "competency_relationships_votes"("relationship_id"); +CREATE INDEX "idx_votes_user" ON "competency_relationships_votes"("user_id"); + +-- foreign keys +ALTER TABLE "competency_relationships" ADD CONSTRAINT "fk_rel_origin" + FOREIGN KEY ("origin_id") REFERENCES "competencies"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "competency_relationships" ADD CONSTRAINT "fk_rel_dest" + FOREIGN KEY ("destination_id") REFERENCES "competencies"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "competency_relationships_votes" ADD CONSTRAINT "fk_vote_relationship" + FOREIGN KEY ("relationship_id") REFERENCES "competency_relationships"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "competency_relationships_votes" ADD CONSTRAINT "fk_vote_user" + FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/server/src/main/resources/db/migration/V7__add_competency_degree.sql b/server/src/main/resources/db/migration/V7__add_competency_degree.sql new file mode 100644 index 0000000..30d981b --- /dev/null +++ b/server/src/main/resources/db/migration/V7__add_competency_degree.sql @@ -0,0 +1,19 @@ +-- Add degree column +ALTER TABLE competencies ADD COLUMN degree INTEGER DEFAULT 0 NOT NULL; + +-- Create index for fast retrieval of low-degree nodes +CREATE INDEX idx_competencies_degree ON competencies(degree); + +-- Backfill existing degrees +WITH calculated_degrees AS ( + SELECT cid, SUM(cnt) as total_degree FROM ( + SELECT origin_id AS cid, COUNT(*) AS cnt FROM competency_relationships GROUP BY origin_id + UNION ALL + SELECT destination_id AS cid, COUNT(*) AS cnt FROM competency_relationships GROUP BY destination_id + ) sub + GROUP BY cid +) +UPDATE competencies c +SET degree = cd.total_degree +FROM calculated_degrees cd +WHERE c.id = cd.cid; diff --git a/src/index.css b/src/index.css index fb23c40..b6c6641 100644 --- a/src/index.css +++ b/src/index.css @@ -26,7 +26,8 @@ --color-foreground: var(--foreground); --font-sans: 'Geist', system-ui, -apple-system, sans-serif; --font-mono: 'Geist Mono', ui-monospace, monospace; - + --font-geist-sans: 'Geist', system-ui, -apple-system, sans-serif; + --font-geist-mono: 'Geist Mono', ui-monospace, monospace; --color-ring: var(--ring); --color-border: var(--border); --color-accent: var(--accent); diff --git a/src/lib/api/competency-relationships.ts b/src/lib/api/competency-relationships.ts index 8891fcd..14a5e7c 100644 --- a/src/lib/api/competency-relationships.ts +++ b/src/lib/api/competency-relationships.ts @@ -1,20 +1,7 @@ import { apiClient } from './client'; -import type { - CompetencyRelationship, - CreateCompetencyRelationshipRequest, -} from './types'; +import type { CompetencyRelationship } from './types'; export const competencyRelationshipsApi = { - create: async ( - data: CreateCompetencyRelationshipRequest - ): Promise => { - const response = await apiClient.post( - '/api/competency-relationships', - data - ); - return response.data; - }, - getById: async (id: string): Promise => { const response = await apiClient.get( `/api/competency-relationships/${id}` diff --git a/src/lib/api/scheduling.ts b/src/lib/api/scheduling.ts new file mode 100644 index 0000000..d7f1de1 --- /dev/null +++ b/src/lib/api/scheduling.ts @@ -0,0 +1,61 @@ +import { apiClient } from './client'; +import type { RelationshipType } from '@/components/session/session-constants'; + +export interface CompetencyInfo { + id: string; + title: string; + description: string; +} + +export interface VoteCounts { + assumes: number; + extendsRelation: number; + matches: number; + unrelated: number; +} + +export interface RelationshipTask { + relationshipId: string | null; + origin: CompetencyInfo; + destination: CompetencyInfo; + pipeline: string; + currentVotes: VoteCounts; +} + +export interface VoteResponse { + relationshipId: string; + success: boolean; + updatedVotes: VoteCounts; + newEntropy: number; +} + +export const schedulingApi = { + /** Fetches the next relationship task, or null if there are none left (HTTP 204). */ + getNextRelationship: async ( + userId: string + ): Promise => { + const response = await apiClient.get( + '/api/scheduling/next-relationship', + { headers: { 'X-User-Id': userId } } + ); + if (response.status === 204) { + return null; + } + return response.data; + }, + + /** Submits a vote for the given competency pair. */ + submitVote: async ( + userId: string, + relationshipType: RelationshipType, + originId: string, + destinationId: string + ): Promise => { + const response = await apiClient.post( + '/api/scheduling/vote', + { originId, destinationId, relationshipType }, + { headers: { 'X-User-Id': userId } } + ); + return response.data; + }, +}; diff --git a/src/lib/api/session-helpers.ts b/src/lib/api/session-helpers.ts index 8b4bdf6..5c79a93 100644 --- a/src/lib/api/session-helpers.ts +++ b/src/lib/api/session-helpers.ts @@ -1,17 +1,20 @@ -/** - * Session API helpers for the session page to use with the Spring Boot API - */ +// Wrappers around the API clients used by the session page import { competenciesApi } from './competencies'; import { competencyRelationshipsApi } from './competency-relationships'; import { competencyResourceLinksApi } from './competency-resource-links'; import { learningResourcesApi } from './learning-resources'; +import { schedulingApi } from './scheduling'; +import type { + RelationshipTask, + VoteResponse as SchedulingVoteResponse, +} from './scheduling'; import type { Competency, LearningResource, - CompetencyRelationship, CompetencyResourceLink, } from './types'; +import type { RelationshipType } from '@/components/session/session-constants'; const GUEST_USER_ID = 'guest'; @@ -49,54 +52,75 @@ export async function getRandomCompetenciesAction(count: number): Promise<{ } } -export async function getRandomLearningResourceAction(): Promise<{ +/** Fetches the next pair from the scheduling pipeline; sets allDone if nothing is left. */ +export async function getNextRelationshipTaskAction(userId: string): Promise<{ success: boolean; - resource?: LearningResource; + task?: RelationshipTask; + allDone?: boolean; error?: string; }> { try { - const resources = await learningResourcesApi.getRandom(1); - if (resources.length > 0) { - return { success: true, resource: resources[0] }; + const task = await schedulingApi.getNextRelationship(userId); + if (task === null) { + return { success: true, allDone: true }; } - return { success: false, error: 'No learning resources available' }; + return { success: true, task }; } catch (error) { return { success: false, error: error instanceof Error ? error.message - : 'Failed to fetch learning resource', + : 'Failed to fetch next relationship task', }; } } -export async function createCompetencyRelationshipAction( - formData: FormData +/** Submits a vote for the given competency pair. */ +export async function submitCompetencyVoteAction( + userId: string, + relationshipType: RelationshipType, + originId: string, + destinationId: string ): Promise<{ success: boolean; - relationship?: CompetencyRelationship; + voteResponse?: SchedulingVoteResponse; error?: string; }> { try { - const relationshipType = formData.get('relationshipType') as string; - const originId = formData.get('originId') as string; - const destinationId = formData.get('destinationId') as string; - - const relationship = await competencyRelationshipsApi.create({ - relationshipType: relationshipType as 'ASSUMES' | 'EXTENDS' | 'MATCHES', + const voteResponse = await schedulingApi.submitVote( + userId, + relationshipType, originId, - destinationId, - userId: GUEST_USER_ID, - }); - return { success: true, relationship }; + destinationId + ); + return { success: true, voteResponse }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to submit vote', + }; + } +} + +export async function getRandomLearningResourceAction(): Promise<{ + success: boolean; + resource?: LearningResource; + error?: string; +}> { + try { + const resources = await learningResourcesApi.getRandom(1); + if (resources.length > 0) { + return { success: true, resource: resources[0] }; + } + return { success: false, error: 'No learning resources available' }; } catch (error) { return { success: false, error: error instanceof Error ? error.message - : 'Failed to create relationship', + : 'Failed to fetch learning resource', }; } } diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index cda03ae..01b7a3e 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -1,3 +1,4 @@ +import { Link } from 'react-router-dom'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; @@ -56,7 +57,7 @@ export function HomePage() { className="h-12 rounded-full bg-[#0a4da2] px-7 text-base font-semibold text-white shadow-[0_18px_45px_-26px_rgba(7,30,84,0.75)] transition hover:bg-[#0d56b5]" asChild > - Start Contributing + Start Contributing
diff --git a/src/pages/SessionPage.tsx b/src/pages/SessionPage.tsx index 9f1be1b..1f20dd0 100644 --- a/src/pages/SessionPage.tsx +++ b/src/pages/SessionPage.tsx @@ -1,8 +1,9 @@ import { useEffect, useState, useCallback, useRef } from 'react'; -import { useSearchParams } from 'react-router-dom'; + import { getRandomCompetenciesAction, - createCompetencyRelationshipAction, + getNextRelationshipTaskAction, + submitCompetencyVoteAction, deleteCompetencyRelationshipAction, createCompetencyResourceLinkAction, deleteCompetencyResourceLinkAction, @@ -96,12 +97,6 @@ const getRelationshipDescription = ( }; export function SessionPage() { - const [searchParams] = useSearchParams(); - const countParam = searchParams.get('count'); - const parsedCount = countParam ? Number(countParam) : NaN; - const count = - Number.isFinite(parsedCount) && parsedCount > 0 ? parsedCount : 2; - const [relation, setRelation] = useState(null); const [resourceMatchType, setResourceMatchType] = useState(null); @@ -128,6 +123,8 @@ export function SessionPage() { const [isCreating, setIsCreating] = useState(false); const [isTransitioning, setIsTransitioning] = useState(false); const [swapRotation, setSwapRotation] = useState(0); + const [allDone, setAllDone] = useState(false); + const [isSwapped, setIsSwapped] = useState(false); const { hoveredValue: hoveredRelation, @@ -185,35 +182,52 @@ export function SessionPage() { setRelation(null); setResourceMatchType(null); + setIsSwapped(false); if (mappingMode === 'competency') { - const result = await getRandomCompetenciesAction(count); + if (!userId) { + setIsTransitioning(false); + return; + } + + const result = await getNextRelationshipTaskAction(userId); if (!result.success) { setError( result.error ?? - 'An unexpected error occurred while fetching competencies.' + 'An unexpected error occurred while fetching the next task.' ); if (isInitialLoad) setCompetencies([]); setIsTransitioning(false); return; } - if (!result.competencies || result.competencies.length === 0) { - if (isInitialLoad) setCompetencies([]); + if (result.allDone) { + setAllDone(true); + setCompetencies([]); setIsTransitioning(false); return; } - if ( - result.competencies.length >= 2 && - result.competencies[0]!.id === result.competencies[1]!.id - ) { - await loadMappingPair(isInitialLoad); + if (!result.task) { + if (isInitialLoad) setCompetencies([]); + setIsTransitioning(false); return; } - setCompetencies(result.competencies); + setAllDone(false); + setCompetencies([ + { + id: result.task.origin.id, + title: result.task.origin.title, + description: result.task.origin.description, + }, + { + id: result.task.destination.id, + title: result.task.destination.title, + description: result.task.destination.description, + }, + ] as Competency[]); setLearningResource(null); } else { const [compResult, resourceResult] = await Promise.all([ @@ -248,7 +262,7 @@ export function SessionPage() { setIsTransitioning(false); }, - [count, mappingMode] + [mappingMode, userId] ); const prevModeRef = useRef(null); @@ -269,7 +283,7 @@ export function SessionPage() { !relation || !userId ) { - setError('Missing required data to create relationship'); + setError('Missing required data to submit vote'); return; } @@ -277,14 +291,21 @@ export function SessionPage() { setError(null); try { - const formData = new FormData(); - formData.set('relationshipType', relation); - formData.set('originId', competencies[0]!.id); - formData.set('destinationId', competencies[1]!.id); - formData.set('userId', userId); - const startTime = Date.now(); - const result = await createCompetencyRelationshipAction(formData); + + const originId = isSwapped + ? competencies[1]!.id + : competencies[0]!.id; + const destinationId = isSwapped + ? competencies[0]!.id + : competencies[1]!.id; + + const result = await submitCompetencyVoteAction( + userId, + relation, + originId, + destinationId + ); const elapsed = Date.now() - startTime; if (elapsed < 300) { @@ -292,7 +313,7 @@ export function SessionPage() { } if (!result.success) { - setError(result.error ?? 'Failed to create relationship'); + setError(result.error ?? 'Failed to submit vote'); setIsCreating(false); return; } @@ -303,7 +324,8 @@ export function SessionPage() { { type: 'completed', mode: 'competency', - relationshipId: result.relationship?.id, + relationshipId: + result.voteResponse?.relationshipId ?? undefined, competencies: competencies ? [...competencies] : undefined, }, ]); @@ -407,6 +429,7 @@ export function SessionPage() { relation, resourceMatchType, userId, + isSwapped, mappingMode, loadMappingPair, ] @@ -618,7 +641,7 @@ export function SessionPage() { ); return ( -
+
@@ -697,7 +720,19 @@ export function SessionPage() { )} - {noCompetencies && !error && ( + {allDone && !error && ( + + + 🎉 All done! + + You've voted on every available competency pair. Great work! + Check back later when more competencies have been added. + + + + )} + + {noCompetencies && !allDone && !error && ( @@ -726,7 +761,7 @@ export function SessionPage() { )} - {!error && !noCompetencies && !notEnough && ( + {!error && !allDone && !noCompetencies && !notEnough && ( <>
{isLoading || !competencies?.length ? ( @@ -852,6 +887,7 @@ export function SessionPage() { size="sm" onClick={() => { setSwapRotation(prev => prev + 180); + setIsSwapped(prev => !prev); setIsTransitioning(true); setTimeout(() => { if (competencies && competencies.length >= 2) {