diff --git a/backend/src/main/java/com/example/flashcards_backend/controller/CardController.java b/backend/src/main/java/com/example/flashcards_backend/controller/CardController.java index 92d3cd3..5e491f5 100644 --- a/backend/src/main/java/com/example/flashcards_backend/controller/CardController.java +++ b/backend/src/main/java/com/example/flashcards_backend/controller/CardController.java @@ -50,9 +50,9 @@ public class CardController { content = @Content( mediaType = "application/json", - schema = @Schema(implementation = CardResponse[].class))) + schema = @Schema(implementation = CardSummary[].class))) @GetMapping - public ResponseEntity> getAllCardResponses( + public ResponseEntity> getAllCardResponses( @RequestParam Long subjectId, @AuthenticationPrincipal Jwt jwt) { log.info("GET /cards: subjectId={}", subjectId); User user = currentUserService.getCurrentUser(jwt); @@ -71,13 +71,13 @@ public ResponseEntity> getAllCardResponses( content = @Content( mediaType = "application/json", - schema = @Schema(implementation = CardResponse.class))) + schema = @Schema(implementation = CardSummary.class))) @ApiResponse( responseCode = "404", description = "Card not found", content = @Content(mediaType = "application/json")) @GetMapping("/{id}") - public ResponseEntity getById( + public ResponseEntity getById( @PathVariable Long id, @AuthenticationPrincipal Jwt jwt) { currentUserService.getCurrentUser(jwt); return ResponseEntity.ok( @@ -134,8 +134,8 @@ public ResponseEntity update( @Valid @RequestBody CardRequest request, @AuthenticationPrincipal Jwt jwt) { currentUserService.getCurrentUser(jwt); - CardResponse cardResponse = cardService.updateCard(id, request); - return ResponseEntity.ok(cardResponse); + CardResponse response = cardService.updateCard(id, request); + return ResponseEntity.ok(response); } @Operation(summary = "Rate card", description = "Rates a card by its ID.") diff --git a/backend/src/main/java/com/example/flashcards_backend/dto/CardResponse.java b/backend/src/main/java/com/example/flashcards_backend/dto/CardResponse.java index 28508c5..077aa0f 100644 --- a/backend/src/main/java/com/example/flashcards_backend/dto/CardResponse.java +++ b/backend/src/main/java/com/example/flashcards_backend/dto/CardResponse.java @@ -1,85 +1,31 @@ package com.example.flashcards_backend.dto; import com.example.flashcards_backend.model.Card; -import com.example.flashcards_backend.model.CardHistory; - -import java.util.HashSet; +import com.fasterxml.jackson.annotation.JsonProperty; import java.util.Set; import java.util.stream.Collectors; -import com.example.flashcards_backend.repository.CardDeckRowProjection; -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonProperty; -import lombok.Builder; - -@Builder public record CardResponse( - @JsonProperty("id") Long id, - @JsonProperty("front") String front, - @JsonProperty("back") String back, - @JsonProperty("hintFront") String hintFront, - @JsonProperty("hintBack") String hintBack, - @JsonProperty("decks") Set decks, - @JsonProperty("avgRating") Double avgRating, - @JsonProperty("viewCount") Integer viewCount, - @JsonProperty("lastViewed") String lastViewed, - @JsonProperty("lastRating") Integer lastRating, - @JsonProperty("subjectId") Long subjectId -) { - - @JsonCreator - public CardResponse { - // canonical constructor; Jackson will call this - } - - public static CardResponse fromEntity(Card card) { - Set deckSummaries = card.getDecks().stream() - .map(DeckSummary::fromEntity) - .collect(Collectors.toSet()); - - CardHistory ch = card.getCardHistories().stream() - .findFirst() - .orElseGet(CardHistory::new); - - return new CardResponse( - card.getId(), - card.getFront(), - card.getBack(), - card.getHintFront(), - card.getHintBack(), - deckSummaries, - ch.getAvgRating(), - ch.getViewCount(), - ch.getLastViewed() != null ? ch.getLastViewed().toString() : null, - ch.getLastRating(), - card.getSubject().getId() - ); - } - - public static CardResponse fromEntity(CardDeckRowProjection cd) { - return new CardResponse( - cd.getCardId(), - cd.getFront(), - cd.getBack(), - cd.getHintFront(), - cd.getHintBack(), - new HashSet<>(), - cd.getAvgRating(), - cd.getViewCount(), - cd.getLastViewed() != null ? cd.getLastViewed().toString() : null, - cd.getLastRating(), - cd.getSubjectId() - ); - } - - public static CardResponse fromEntity(CreateCardResponse ccr) { - return CardResponse.builder() - .id(ccr.id()) - .front(ccr.front()) - .back(ccr.back()) - .hintFront(ccr.hintFront()) - .hintBack(ccr.hintBack()) - .decks(new HashSet<>(ccr.decks())) - .build(); - } -} \ No newline at end of file + @JsonProperty("id") Long id, + @JsonProperty("front") String front, + @JsonProperty("back") String back, + @JsonProperty("hintFront") String hintFront, + @JsonProperty("hintBack") String hintBack, + @JsonProperty("decks") Set decks, + @JsonProperty("subjectId") Long subjectId) { + public static CardResponse fromEntity(Card card) { + Set deckSummaries = card.getDecks().stream() + .map(DeckSummary::fromEntity) + .collect(Collectors.toSet()); + + return new CardResponse( + card.getId(), + card.getFront(), + card.getBack(), + card.getHintFront(), + card.getHintBack(), + deckSummaries, + card.getSubject().getId() + ); + } +} diff --git a/backend/src/main/java/com/example/flashcards_backend/dto/CardSummary.java b/backend/src/main/java/com/example/flashcards_backend/dto/CardSummary.java new file mode 100644 index 0000000..c7d3484 --- /dev/null +++ b/backend/src/main/java/com/example/flashcards_backend/dto/CardSummary.java @@ -0,0 +1,81 @@ +package com.example.flashcards_backend.dto; + +import com.example.flashcards_backend.model.Card; +import com.example.flashcards_backend.model.CardHistory; + +import java.util.HashSet; +import java.util.Set; +import java.util.stream.Collectors; + +import com.example.flashcards_backend.repository.CardDeckRowProjection; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Builder; + +@Builder +public record CardSummary( + @JsonProperty("id") Long id, + @JsonProperty("front") String front, + @JsonProperty("back") String back, + @JsonProperty("hintFront") String hintFront, + @JsonProperty("hintBack") String hintBack, + @JsonProperty("decks") Set decks, + @JsonProperty("avgRating") Double avgRating, + @JsonProperty("viewCount") Integer viewCount, + @JsonProperty("lastViewed") String lastViewed, + @JsonProperty("lastRating") Integer lastRating, + @JsonProperty("subjectId") Long subjectId) { + + @JsonCreator + public CardSummary { + // canonical constructor; Jackson will call this + } + + public static CardSummary fromEntity(Card card, CardHistory ch) { + Set deckSummaries = + card.getDecks().stream().map(DeckSummary::fromEntity).collect(Collectors.toSet()); + + if (ch == null) { + ch = CardHistory.builder().avgRating(0.0).viewCount(0).lastViewed(null).lastRating(0).build(); + } + + return new CardSummary( + card.getId(), + card.getFront(), + card.getBack(), + card.getHintFront(), + card.getHintBack(), + deckSummaries, + ch.getAvgRating(), + ch.getViewCount(), + ch.getLastViewed() != null ? ch.getLastViewed().toString() : null, + ch.getLastRating(), + card.getSubject().getId()); + } + + public static CardSummary fromEntity(CardDeckRowProjection cd) { + return new CardSummary( + cd.getCardId(), + cd.getFront(), + cd.getBack(), + cd.getHintFront(), + cd.getHintBack(), + new HashSet<>(), + cd.getAvgRating(), + cd.getViewCount(), + cd.getLastViewed() != null ? cd.getLastViewed().toString() : null, + cd.getLastRating(), + cd.getSubjectId()); + } + + public static CardSummary fromEntity(CreateCardResponse ccr) { + return CardSummary.builder() + .id(ccr.id()) + .front(ccr.front()) + .back(ccr.back()) + .hintFront(ccr.hintFront()) + .hintBack(ccr.hintBack()) + .decks(new HashSet<>(ccr.decks())) + .build(); + } +} diff --git a/backend/src/main/java/com/example/flashcards_backend/dto/CsvUploadResponseDto.java b/backend/src/main/java/com/example/flashcards_backend/dto/CsvUploadResponseDto.java index 49d6d59..2040962 100644 --- a/backend/src/main/java/com/example/flashcards_backend/dto/CsvUploadResponseDto.java +++ b/backend/src/main/java/com/example/flashcards_backend/dto/CsvUploadResponseDto.java @@ -5,4 +5,4 @@ import java.util.List; @Builder -public record CsvUploadResponseDto(List saved, List duplicates) {} \ No newline at end of file +public record CsvUploadResponseDto(List saved, List duplicates) {} \ No newline at end of file diff --git a/backend/src/main/java/com/example/flashcards_backend/dto/UserDto.java b/backend/src/main/java/com/example/flashcards_backend/dto/UserDto.java index c9405c3..a912eb2 100644 --- a/backend/src/main/java/com/example/flashcards_backend/dto/UserDto.java +++ b/backend/src/main/java/com/example/flashcards_backend/dto/UserDto.java @@ -5,9 +5,4 @@ import java.util.UUID; @Builder -public record UserDto( - String username, - UUID id, - boolean isActive -) { -} +public record UserDto(String username, UUID id, boolean isActive) {} diff --git a/backend/src/main/java/com/example/flashcards_backend/dto/UserStatsResponse.java b/backend/src/main/java/com/example/flashcards_backend/dto/UserStatsResponse.java index d28faac..89521fc 100644 --- a/backend/src/main/java/com/example/flashcards_backend/dto/UserStatsResponse.java +++ b/backend/src/main/java/com/example/flashcards_backend/dto/UserStatsResponse.java @@ -5,8 +5,8 @@ @Builder public record UserStatsResponse( Long totalCards, - CardResponse hardestCard, - CardResponse mostViewedCard, + CardSummary hardestCard, + CardSummary mostViewedCard, Long totalCardViews, Long totalLastRating1, Long totalLastRating2, diff --git a/backend/src/main/java/com/example/flashcards_backend/model/Card.java b/backend/src/main/java/com/example/flashcards_backend/model/Card.java index 387dce7..a91cb59 100644 --- a/backend/src/main/java/com/example/flashcards_backend/model/Card.java +++ b/backend/src/main/java/com/example/flashcards_backend/model/Card.java @@ -17,7 +17,7 @@ @Setter @RequiredArgsConstructor @AllArgsConstructor -@Builder(builderClassName = "CardBuilder", toBuilder = true) +@Builder(toBuilder = true) @Table(name = "card", uniqueConstraints = @UniqueConstraint(columnNames = {"front", "back"})) public class Card { @Id @@ -53,12 +53,6 @@ public class Card { @NotNull private Subject subject; - @OneToMany(mappedBy = "card", cascade = CascadeType.REMOVE, orphanRemoval = true) - @EqualsAndHashCode.Exclude - @ToString.Exclude - @Singular - private final Set cardHistories = new HashSet<>(); - @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "user_id", nullable = false) private User user; @@ -118,44 +112,6 @@ public Set getDeckNames() { return deckNames; } - public void addCardHistory(CardHistory cardHistory) { - cardHistory.setCard(this); - } - - @SuppressWarnings("unused") - public static class CardBuilder { - private Long id; - private String front; - private String back; - private String hintFront; - private String hintBack; - private Set decks = new HashSet<>(); - private Set cardHistories = new HashSet<>(); - private Subject subject; - private User user; - - public CardBuilder cardHistory(CardHistory cardHistory) { - this.cardHistories.add(cardHistory); - return this; - } - - public Card build() { - Card card = new Card(); - card.id = this.id; - card.front = this.front; - card.back = this.back; - card.hintFront = this.hintFront; - card.hintBack = this.hintBack; - card.decks = this.decks; - card.subject = this.subject; - card.user = this.user; - for (CardHistory ch : this.cardHistories) { - card.addCardHistory(ch); - } - return card; - } - } - @Override public final boolean equals(Object o) { if (this == o) return true; diff --git a/backend/src/main/java/com/example/flashcards_backend/model/CardHistory.java b/backend/src/main/java/com/example/flashcards_backend/model/CardHistory.java index 9a4c278..6e82b99 100644 --- a/backend/src/main/java/com/example/flashcards_backend/model/CardHistory.java +++ b/backend/src/main/java/com/example/flashcards_backend/model/CardHistory.java @@ -36,11 +36,4 @@ public class CardHistory extends BaseEntity { @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "user_id", nullable = false) private User user; - - public void setCard(Card card) { - this.card = card; - if (card != null) { - card.getCardHistories().add(this); - } - } } diff --git a/backend/src/main/java/com/example/flashcards_backend/model/Subject.java b/backend/src/main/java/com/example/flashcards_backend/model/Subject.java index 9cc6bc6..9519510 100644 --- a/backend/src/main/java/com/example/flashcards_backend/model/Subject.java +++ b/backend/src/main/java/com/example/flashcards_backend/model/Subject.java @@ -2,8 +2,6 @@ import com.fasterxml.jackson.annotation.JsonBackReference; import jakarta.persistence.*; -import java.util.HashSet; -import java.util.Set; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @@ -38,12 +36,6 @@ public class Subject extends BaseEntity { @Enumerated(EnumType.STRING) private CardOrder cardOrder; - @OneToMany(mappedBy = "subject", cascade = CascadeType.ALL, orphanRemoval = true) - private Set decks = new HashSet<>(); - - @OneToMany(mappedBy = "subject", cascade = CascadeType.ALL, orphanRemoval = true) - private Set cards = new HashSet<>(); - @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "user_id", nullable = false) @JsonBackReference diff --git a/backend/src/main/java/com/example/flashcards_backend/model/User.java b/backend/src/main/java/com/example/flashcards_backend/model/User.java index 1f377d5..32ff5e0 100644 --- a/backend/src/main/java/com/example/flashcards_backend/model/User.java +++ b/backend/src/main/java/com/example/flashcards_backend/model/User.java @@ -1,11 +1,8 @@ package com.example.flashcards_backend.model; -import com.fasterxml.jackson.annotation.JsonManagedReference; import jakarta.persistence.*; import lombok.*; -import java.util.ArrayList; -import java.util.List; import java.util.UUID; @Entity @@ -17,43 +14,16 @@ @Builder public class User { - @Id - // Assigned manually for now - private UUID id; + @Id + // Assigned manually for now + private UUID id; - @Column(nullable = false, unique = true) - private String username; + @Column(nullable = false, unique = true) + private String username; - @Column(nullable = false) - private boolean isActive = true; + @Column(nullable = false) + private boolean isActive = true; - @Column(name = "auth0_id", length = 100, unique = true) - private String auth0Id; - - @Builder.Default - @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) - private List cards = new ArrayList<>(); - - @Builder.Default - @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) - private List decks = new ArrayList<>(); - - @Builder.Default - @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) - private List cardHistories = new ArrayList<>(); - - @Builder.Default - @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) - @JsonManagedReference - private List subjects = new ArrayList<>(); - - public void addSubject(Subject subject) { - subjects.add(subject); - subject.setUser(this); - } - - public void removeSubject(Subject subject) { - subjects.remove(subject); - subject.setUser(null); - } + @Column(name = "auth0_id", length = 100, unique = true) + private String auth0Id; } diff --git a/backend/src/main/java/com/example/flashcards_backend/repository/CardRepository.java b/backend/src/main/java/com/example/flashcards_backend/repository/CardRepository.java index c19a8e3..8f06f47 100644 --- a/backend/src/main/java/com/example/flashcards_backend/repository/CardRepository.java +++ b/backend/src/main/java/com/example/flashcards_backend/repository/CardRepository.java @@ -4,6 +4,8 @@ import java.util.List; import java.util.Optional; import java.util.UUID; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; @@ -35,7 +37,7 @@ public interface CardRepository extends JpaRepository { s.id AS subjectId FROM Card c LEFT JOIN c.decks d - LEFT JOIN c.cardHistories ch + LEFT JOIN CardHistory ch ON ch.card = c LEFT JOIN c.subject s WHERE (:subjectId IS NULL OR s.id = :subjectId) AND (:cardId IS NULL OR c.id = :cardId) @@ -60,16 +62,32 @@ SELECT COUNT(c) """) long countByUserId(@Param("userId") UUID userId); - Optional findTopByCardHistories_User_IdOrderByCardHistories_AvgRatingDesc(UUID userId); + @Query( + """ + SELECT c + FROM Card c + JOIN CardHistory ch ON ch.card = c + WHERE ch.user.id = :userId + ORDER BY ch.avgRating DESC + """) + List findHardestByUserIdInternal(@Param("userId") UUID userId, Pageable pageable); default Optional findHardestByUserId(UUID userId) { - return findTopByCardHistories_User_IdOrderByCardHistories_AvgRatingDesc(userId); + return findHardestByUserIdInternal(userId, PageRequest.of(0, 1)).stream().findFirst(); } - Optional findTopByCardHistories_User_IdOrderByCardHistories_ViewCountDesc(UUID userId); + @Query( + """ + SELECT c + FROM Card c + JOIN CardHistory ch ON ch.card = c + WHERE ch.user.id = :userId + ORDER BY ch.viewCount DESC + """) + List findMostViewedByUserIdInternal(@Param("userId") UUID userId, Pageable pageable); default Optional findMostViewedByUserId(UUID userId) { - return findTopByCardHistories_User_IdOrderByCardHistories_ViewCountDesc(userId); + return findMostViewedByUserIdInternal(userId, PageRequest.of(0, 1)).stream().findFirst(); } @Modifying @@ -106,6 +124,7 @@ default Long countUnviewedByUserId(UUID userId) { FROM Card c LEFT JOIN c.decks d WHERE c.subject.id = :subjectId + ORDER BY c.id """) List findExportRowsBySubjectId(@Param("subjectId") Long subjectId); @@ -120,7 +139,8 @@ default Long countUnviewedByUserId(UUID userId) { FROM Card c LEFT JOIN c.decks d WHERE d.id = :deckId - GROUP BY c.id + GROUP BY c.id, c.front, c.back, c.hintFront, c.hintBack, d.name + ORDER BY c.id """) List findExportRowsByDeckId(@Param("deckId") Long deckId); } diff --git a/backend/src/main/java/com/example/flashcards_backend/service/CardHistoryService.java b/backend/src/main/java/com/example/flashcards_backend/service/CardHistoryService.java index 4c882c8..133c82a 100644 --- a/backend/src/main/java/com/example/flashcards_backend/service/CardHistoryService.java +++ b/backend/src/main/java/com/example/flashcards_backend/service/CardHistoryService.java @@ -57,19 +57,26 @@ public RateCardResponse recordRatingForUser(Long cardId, int rating, User user) return RateCardResponse.fromHistory(ch); } - private static CardHistory getOrCreateCardHistoryForUser(User user, Card card) { + @Transactional + public void deleteByCardIds(List ids) { + log.info("Deleting card history for cards with ids {}", ids); + cardHistoryRepository.deleteByCardIds(ids); + } + + /* HELPERS */ + + private CardHistory getOrCreateCardHistoryForUser(User user, Card card) { log.info( "Getting or creating card history for user {} and card {}", user.getId(), card.getId()); - return card.getCardHistories().stream() - .filter(ch -> ch.getUser().getId() == user.getId()) - .findFirst() - .orElseGet( - () -> { - log.info("No existing history found, creating new one"); - var ch = CardHistory.builder().user(user).viewCount(0).build(); - ch.setCard(card); - return ch; - }); + var cardHistory = cardHistoryRepository.findByCardIdAndUserId(card.getId(), user.getId()); + if (cardHistory.isPresent()) { + log.info("Existing history found"); + return cardHistory.get(); + } + log.info("No existing history found, creating new one"); + var ch = CardHistory.builder().user(user).viewCount(0).build(); + ch.setCard(card); + return ch; } private CardHistory getCardHistory(Long cardId, User user) { @@ -82,10 +89,4 @@ private Card getCard(Long cardId) { log.info("Fetching card with id {}", cardId); return cardRepository.findById(cardId).orElseThrow(() -> new CardNotFoundException(cardId)); } - - @Transactional - public void deleteByCardIds(List ids) { - log.info("Deleting card history for cards with ids {}", ids); - cardHistoryRepository.deleteByCardIds(ids); - } } diff --git a/backend/src/main/java/com/example/flashcards_backend/service/CardService.java b/backend/src/main/java/com/example/flashcards_backend/service/CardService.java index d928822..b4ed209 100644 --- a/backend/src/main/java/com/example/flashcards_backend/service/CardService.java +++ b/backend/src/main/java/com/example/flashcards_backend/service/CardService.java @@ -26,13 +26,13 @@ public class CardService { private final CardDeckService cardDeckService; private final SubjectService subjectService; - protected List getAllCardResponsesFromSubject(Long subjectId) { + protected List getAllCardResponsesFromSubject(Long subjectId) { List rows = cardRepository.findCardDeckRowsBySubjectId(subjectId); return mapRowsToResponses(rows); } @Transactional(readOnly = true) - public List getAllCardResponsesForUserAndSubject(User user, Long subjectId) { + public List getAllCardResponsesForUserAndSubject(User user, Long subjectId) { Subject subject = subjectService.findById(subjectId); if (!subject.getUser().equals(user)) { throw new IllegalArgumentException("User does not own subject"); @@ -41,7 +41,7 @@ public List getAllCardResponsesForUserAndSubject(User user, Long s } @Transactional(readOnly = true) - public CardResponse getCardResponseById(Long id) { + public CardSummary getCardResponseById(Long id) { log.info("Getting card response for id {}", id); List rows = cardRepository.findCardDeckRowsByCardId(id); if (rows.isEmpty()) { @@ -204,13 +204,13 @@ private static Set getDeckNames(CardRequest request) { return request.deckNames() == null ? Set.of() : request.deckNames(); } - private List mapRowsToResponses(List rows) { + private List mapRowsToResponses(List rows) { log.info("Mapping {} projection rows to CardResponses", rows.size()); - Map cardMap = new LinkedHashMap<>(); + Map cardMap = new LinkedHashMap<>(); for (CardDeckRowProjection row : rows) { - CardResponse existing = cardMap.get(row.getCardId()); + CardSummary existing = cardMap.get(row.getCardId()); if (existing == null) { - existing = CardResponse.fromEntity(row); + existing = CardSummary.fromEntity(row); cardMap.put(row.getCardId(), existing); } if (row.getDeckId() != null) { diff --git a/backend/src/main/java/com/example/flashcards_backend/service/CsvUploadServiceImpl.java b/backend/src/main/java/com/example/flashcards_backend/service/CsvUploadServiceImpl.java index 505a83c..6a8e792 100644 --- a/backend/src/main/java/com/example/flashcards_backend/service/CsvUploadServiceImpl.java +++ b/backend/src/main/java/com/example/flashcards_backend/service/CsvUploadServiceImpl.java @@ -1,7 +1,7 @@ package com.example.flashcards_backend.service; import com.example.flashcards_backend.dto.CardRequest; -import com.example.flashcards_backend.dto.CardResponse; +import com.example.flashcards_backend.dto.CardSummary; import com.example.flashcards_backend.dto.CsvUploadResponseDto; import com.example.flashcards_backend.exception.InvalidCsvFormatException; import com.example.flashcards_backend.exception.SubjectNotFoundException; @@ -66,7 +66,7 @@ public CsvUploadResponseDto uploadCsv(InputStream csvStream, Long subjectId) Map> recordsGroupedByDuplication = partitionByDuplicate(valid, subjectId); - List duplicates = buildDuplicateResponses(recordsGroupedByDuplication); + List duplicates = buildDuplicateResponses(recordsGroupedByDuplication); List toSave = buildNewCardRequests(subjectId, recordsGroupedByDuplication); log.info( "Final report: Valid: {}, Invalid: {}, Duplicates: {}, To save: {}", @@ -76,8 +76,8 @@ public CsvUploadResponseDto uploadCsv(InputStream csvStream, Long subjectId) toSave.size()); log.info("Saving {} cards", toSave.size()); - List saved = - cardService.createCards(toSave).stream().map(CardResponse::fromEntity).toList(); + List saved = + cardService.createCards(toSave).stream().map(CardSummary::fromEntity).toList(); log.info("Cards saved"); log.info("CSV upload complete. Building response."); @@ -110,10 +110,10 @@ private List buildNewCardRequests( return toSave; } - private static List buildDuplicateResponses( + private static List buildDuplicateResponses( Map> recordsGroupedByDuplication) { return recordsGroupedByDuplication.get(true).stream() - .map(r -> CardResponse.builder().front(r.get(FRONT)).back(r.get(BACK)).build()) + .map(r -> CardSummary.builder().front(r.get(FRONT)).back(r.get(BACK)).build()) .toList(); } diff --git a/backend/src/main/java/com/example/flashcards_backend/service/UserStatsService.java b/backend/src/main/java/com/example/flashcards_backend/service/UserStatsService.java index 9703964..2048751 100644 --- a/backend/src/main/java/com/example/flashcards_backend/service/UserStatsService.java +++ b/backend/src/main/java/com/example/flashcards_backend/service/UserStatsService.java @@ -1,55 +1,63 @@ package com.example.flashcards_backend.service; -import com.example.flashcards_backend.dto.CardResponse; +import com.example.flashcards_backend.dto.CardSummary; import com.example.flashcards_backend.dto.UserStatsResponse; import com.example.flashcards_backend.model.Card; +import com.example.flashcards_backend.model.CardHistory; import com.example.flashcards_backend.repository.CardHistoryRepository; import com.example.flashcards_backend.repository.CardRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - import java.util.Map; import java.util.Optional; import java.util.UUID; import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor public class UserStatsService { - private final CardRepository cardRepository; - private final CardHistoryRepository cardHistoryRepository; - - @Transactional(readOnly = true) - public UserStatsResponse getForUserId(UUID userId) { - - Long totalCards = cardRepository.countByUserId(userId); - Long totalCardViews = cardHistoryRepository.totalViewCountByUserId(userId); - Optional mostViewedCard = cardRepository.findMostViewedByUserId(userId); - Optional hardestCard = cardRepository.findHardestByUserId(userId); - Map countsForEachLastViewedRating = getCountsForEachLastViewedRating(userId); - Long totalUnviewedCards = cardRepository.countUnviewedByUserId(userId); - - return UserStatsResponse.builder() - .totalCards(totalCards) - .hardestCard(hardestCard.map(CardResponse::fromEntity).orElse(null)) - .mostViewedCard(mostViewedCard.map(CardResponse::fromEntity).orElse(null)) - .totalCardViews(totalCardViews) - .totalLastRating1(countsForEachLastViewedRating.getOrDefault(1, 0L)) - .totalLastRating2(countsForEachLastViewedRating.getOrDefault(2, 0L)) - .totalLastRating3(countsForEachLastViewedRating.getOrDefault(3, 0L)) - .totalLastRating4(countsForEachLastViewedRating.getOrDefault(4, 0L)) - .totalLastRating5(countsForEachLastViewedRating.getOrDefault(5, 0L)) - .totalUnviewedCards(totalUnviewedCards) - .build(); - } - - private Map getCountsForEachLastViewedRating(UUID userId) { - return cardHistoryRepository.countByLastRatingForUser(userId).stream() - .collect(Collectors.toMap( - row -> (Integer) row[0], - row -> (Long) row[1] - )); - } + private final CardRepository cardRepository; + private final CardHistoryRepository cardHistoryRepository; + + @Transactional(readOnly = true) + public UserStatsResponse getForUserId(UUID userId) { + + Long totalCards = cardRepository.countByUserId(userId); + Long totalCardViews = cardHistoryRepository.totalViewCountByUserId(userId); + Optional mostViewedCard = cardRepository.findMostViewedByUserId(userId); + Optional hardestCard = cardRepository.findHardestByUserId(userId); + Map countsForEachLastViewedRating = getCountsForEachLastViewedRating(userId); + Long totalUnviewedCards = cardRepository.countUnviewedByUserId(userId); + CardHistory hardestCardHistory = hardestCard.map(card -> getHistory(userId, card)).orElse(null); + CardHistory mostViewedCardHistory = + mostViewedCard.map(card -> getHistory(userId, card)).orElse(null); + + return UserStatsResponse.builder() + .totalCards(totalCards) + .hardestCard( + hardestCard.map(card -> CardSummary.fromEntity(card, hardestCardHistory)).orElse(null)) + .mostViewedCard( + mostViewedCard + .map(card -> CardSummary.fromEntity(card, mostViewedCardHistory)) + .orElse(null)) + .totalCardViews(totalCardViews) + .totalLastRating1(countsForEachLastViewedRating.getOrDefault(1, 0L)) + .totalLastRating2(countsForEachLastViewedRating.getOrDefault(2, 0L)) + .totalLastRating3(countsForEachLastViewedRating.getOrDefault(3, 0L)) + .totalLastRating4(countsForEachLastViewedRating.getOrDefault(4, 0L)) + .totalLastRating5(countsForEachLastViewedRating.getOrDefault(5, 0L)) + .totalUnviewedCards(totalUnviewedCards) + .build(); + } + + private CardHistory getHistory(UUID userId, Card card) { + return cardHistoryRepository.findByCardIdAndUserId(card.getId(), userId).orElse(null); + } + + private Map getCountsForEachLastViewedRating(UUID userId) { + return cardHistoryRepository.countByLastRatingForUser(userId).stream() + .collect(Collectors.toMap(row -> (Integer) row[0], row -> (Long) row[1])); + } } diff --git a/backend/src/test/java/com/example/flashcards_backend/controller/CsvExportControllerTest.java b/backend/src/test/java/com/example/flashcards_backend/controller/CsvExportControllerTest.java index fe36749..4518a06 100644 --- a/backend/src/test/java/com/example/flashcards_backend/controller/CsvExportControllerTest.java +++ b/backend/src/test/java/com/example/flashcards_backend/controller/CsvExportControllerTest.java @@ -44,8 +44,9 @@ void setUp() { deck2 = Deck.builder().name("Deck 2").subject(subject1).user(testUser).build(); deckRepository.saveAllAndFlush(List.of(deck1, deck2)); card1 = Card.builder().front("Front 1").back("Back 1").user(testUser).subject(subject1).build(); + cardRepository.saveAndFlush(card1); card2 = Card.builder().front("Front 2").back("Back 2").user(testUser).subject(subject1).build(); - cardRepository.saveAllAndFlush(List.of(card1, card2)); + cardRepository.saveAndFlush(card2); card1.addDecks(Set.of(deck1, deck2)); card2.addDecks(Set.of(deck1)); cardRepository.saveAllAndFlush(List.of(card1, card2)); diff --git a/backend/src/test/java/com/example/flashcards_backend/controller/CsvUploadControllerTest.java b/backend/src/test/java/com/example/flashcards_backend/controller/CsvUploadControllerTest.java index 7256c78..fbaf691 100644 --- a/backend/src/test/java/com/example/flashcards_backend/controller/CsvUploadControllerTest.java +++ b/backend/src/test/java/com/example/flashcards_backend/controller/CsvUploadControllerTest.java @@ -4,7 +4,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; -import com.example.flashcards_backend.dto.CardResponse; +import com.example.flashcards_backend.dto.CardSummary; import com.example.flashcards_backend.dto.CsvUploadResponseDto; import com.example.flashcards_backend.integration.AbstractIntegrationTest; @@ -74,7 +74,7 @@ void uploadCsv_validFile_returnsOkWithResponseBody() throws Exception { assertThat(responseDto.saved()) .extracting("back") .containsExactlyInAnyOrder("b", "d", "withHintB"); - Optional withHintF = + Optional withHintF = responseDto.saved().stream() .filter(cardResponse -> cardResponse.front().equals("withHintF")) .findFirst(); @@ -83,13 +83,13 @@ void uploadCsv_validFile_returnsOkWithResponseBody() throws Exception { assertThat(withHintF.get().hintFront()).isEqualTo("h1"); assertThat(withHintF.get().hintBack()).isEqualTo("h2"); - Optional a = + Optional a = responseDto.saved().stream() .filter(cardResponse -> cardResponse.front().equals("a")) .findFirst(); assertThat(a).isPresent(); assertThat(a.get().decks()).isEmpty(); - Optional c = + Optional c = responseDto.saved().stream() .filter(cardResponse -> cardResponse.front().equals("c")) .findFirst(); diff --git a/backend/src/test/java/com/example/flashcards_backend/dto/CardResponseTest.java b/backend/src/test/java/com/example/flashcards_backend/dto/CardSummaryTest.java similarity index 58% rename from backend/src/test/java/com/example/flashcards_backend/dto/CardResponseTest.java rename to backend/src/test/java/com/example/flashcards_backend/dto/CardSummaryTest.java index 3212a2d..afad857 100644 --- a/backend/src/test/java/com/example/flashcards_backend/dto/CardResponseTest.java +++ b/backend/src/test/java/com/example/flashcards_backend/dto/CardSummaryTest.java @@ -11,7 +11,7 @@ import java.time.LocalDateTime; -class CardResponseTest { +class CardSummaryTest { @BeforeEach void setUp() { @@ -22,11 +22,11 @@ void setUp() { @Test void testCardResponseCreation() { - CardResponse cardResponse = new CardResponse( 1L, "Front Text", "Back Text", null, null, null, null, null, null, null, 1L); + CardSummary cardSummary = new CardSummary( 1L, "Front Text", "Back Text", null, null, null, null, null, null, null, 1L); - assertThat(cardResponse.id()).isEqualTo(1L); - assertThat(cardResponse.front()).isEqualTo("Front Text"); - assertThat(cardResponse.back()).isEqualTo("Back Text"); + assertThat(cardSummary.id()).isEqualTo(1L); + assertThat(cardSummary.front()).isEqualTo("Front Text"); + assertThat(cardSummary.back()).isEqualTo("Back Text"); } @Test @@ -46,15 +46,15 @@ void testCardResponseFromEntity() { .build(); cardHistory.setCard(card); - CardResponse cardResponse = CardResponse.fromEntity(card); - assertThat(cardResponse.id()).isEqualTo(2L); - assertThat(cardResponse.front()).isEqualTo("Card Front"); - assertThat(cardResponse.back()).isEqualTo("Card Back"); - assertThat(cardResponse.avgRating()).isEqualTo(3.5); - assertThat(cardResponse.viewCount()).isEqualTo(10); - assertThat(cardResponse.lastViewed()).isEqualTo(now.toString()); - assertThat(cardResponse.lastRating()).isEqualTo(4); - assertThat(cardResponse.subjectId()).isEqualTo(1L); + CardSummary cardSummary = CardSummary.fromEntity(card, cardHistory); + assertThat(cardSummary.id()).isEqualTo(2L); + assertThat(cardSummary.front()).isEqualTo("Card Front"); + assertThat(cardSummary.back()).isEqualTo("Card Back"); + assertThat(cardSummary.avgRating()).isEqualTo(3.5); + assertThat(cardSummary.viewCount()).isEqualTo(10); + assertThat(cardSummary.lastViewed()).isEqualTo(now.toString()); + assertThat(cardSummary.lastRating()).isEqualTo(4); + assertThat(cardSummary.subjectId()).isEqualTo(1L); } } \ No newline at end of file diff --git a/backend/src/test/java/com/example/flashcards_backend/integration/CsvUploadIT.java b/backend/src/test/java/com/example/flashcards_backend/integration/CsvUploadIT.java index 117bb8a..92a7d59 100644 --- a/backend/src/test/java/com/example/flashcards_backend/integration/CsvUploadIT.java +++ b/backend/src/test/java/com/example/flashcards_backend/integration/CsvUploadIT.java @@ -33,96 +33,81 @@ @Import(TestSecurityConfig.class) class CsvUploadIT { - public static final UUID ID = UUID.fromString("00000000-0000-0000-0000-000000000000"); - private Long subjectId; - - @LocalServerPort - private int port; - - @Autowired - private TestRestTemplate restTemplate; - - @Autowired - private SubjectRepository subjectRepository; - - @Autowired - private UserRepository userRepository; - - @Autowired - private CardRepository cardRepository; - - @Autowired - private DeckRepository deckRepository; - - @Autowired - private CurrentUserService currentUserService; - - @BeforeEach - void setUp() { - clearDatabase(); - // Create a test user - User testUser = User.builder() - .username("testuser") - .auth0Id("auth0|test-user-id") - .id(ID) - .build(); - - Subject subject = Subject.builder() - .name("German") - .build(); - - testUser.addSubject(subject); - - // Save the user, cascades will persist subject - userRepository.saveAndFlush(testUser); - - Subject persistedSubject = subjectRepository.findByName(subject.getName()) - .orElseThrow(() -> new RuntimeException("Subject not found with name: " + subject.getName())); - - subjectId = persistedSubject.getId(); - - } - - @Test - void uploadCsv_fileOnClasspath_returns200() { - String uri = UriComponentsBuilder - .fromUriString("http://localhost") - .port(port) - .path("/csv/{id}") - .buildAndExpand(subjectId) - .toUriString(); - ClassPathResource resource = new ClassPathResource("csv/vocab_upload_1.csv"); - - MultiValueMap body = new LinkedMultiValueMap<>(); - body.add("file", resource); - - ResponseEntity response = restTemplate.postForEntity( - uri, - new HttpEntity<>(body, createMultipartHeaders()), - CsvUploadResponseDto.class - ); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody()).isNotNull(); - assertThat(response.getBody().saved()).isNotNull(); - } - - @AfterEach - void tearDown() { - clearDatabase(); - } - - private HttpHeaders createMultipartHeaders() { - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.MULTIPART_FORM_DATA); - headers.setBearerAuth("test-token"); // will be accepted by TestSecurityConfig - return headers; - } - - void clearDatabase() { - cardRepository.deleteAll(); - deckRepository.deleteAll(); - subjectRepository.deleteAll(); - userRepository.deleteAll(); - } + public static final UUID ID = UUID.fromString("00000000-0000-0000-0000-000000000000"); + private Long subjectId; + + @LocalServerPort private int port; + + @Autowired private TestRestTemplate restTemplate; + + @Autowired private SubjectRepository subjectRepository; + + @Autowired private UserRepository userRepository; + + @Autowired private CardRepository cardRepository; + + @Autowired private DeckRepository deckRepository; + + @Autowired private CurrentUserService currentUserService; + + @BeforeEach + void setUp() { + clearDatabase(); + // Create a test user + User testUser = + User.builder().username("testuser").auth0Id("auth0|test-user-id").id(ID).build(); + userRepository.saveAndFlush(testUser); + + Subject subject = Subject.builder().name("German").user(testUser).build(); + subjectRepository.saveAndFlush(subject); + + Subject persistedSubject = + subjectRepository + .findByName(subject.getName()) + .orElseThrow( + () -> new RuntimeException("Subject not found with name: " + subject.getName())); + + subjectId = persistedSubject.getId(); + } + + @Test + void uploadCsv_fileOnClasspath_returns200() { + String uri = + UriComponentsBuilder.fromUriString("http://localhost") + .port(port) + .path("/csv/{id}") + .buildAndExpand(subjectId) + .toUriString(); + ClassPathResource resource = new ClassPathResource("csv/vocab_upload_1.csv"); + + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("file", resource); + + ResponseEntity response = + restTemplate.postForEntity( + uri, new HttpEntity<>(body, createMultipartHeaders()), CsvUploadResponseDto.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().saved()).isNotNull(); + } + + @AfterEach + void tearDown() { + clearDatabase(); + } + + private HttpHeaders createMultipartHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.MULTIPART_FORM_DATA); + headers.setBearerAuth("test-token"); // will be accepted by TestSecurityConfig + return headers; + } + + void clearDatabase() { + cardRepository.deleteAll(); + deckRepository.deleteAll(); + subjectRepository.deleteAll(); + userRepository.deleteAll(); + } } diff --git a/backend/src/test/java/com/example/flashcards_backend/service/CardServiceTest.java b/backend/src/test/java/com/example/flashcards_backend/service/CardServiceTest.java index 7c1b57e..a58f788 100644 --- a/backend/src/test/java/com/example/flashcards_backend/service/CardServiceTest.java +++ b/backend/src/test/java/com/example/flashcards_backend/service/CardServiceTest.java @@ -5,7 +5,7 @@ import static org.mockito.Mockito.*; import com.example.flashcards_backend.dto.CardRequest; -import com.example.flashcards_backend.dto.CardResponse; +import com.example.flashcards_backend.dto.CardSummary; import com.example.flashcards_backend.dto.CreateCardResponse; import com.example.flashcards_backend.dto.HintRequest; import com.example.flashcards_backend.exception.CardNotFoundException; @@ -206,7 +206,7 @@ void testGetCardById() { when(cardRepository.findCardDeckRowsByCardId(CARD_2_ID)) .thenReturn(List.of(cardDeckRowProjection3)); - CardResponse foundCard = cardService.getCardResponseById(CARD_1_ID); + CardSummary foundCard = cardService.getCardResponseById(CARD_1_ID); assertThat(foundCard.id()).isEqualTo(CARD_1_ID); assertThat(foundCard.front()).isEqualTo("Front 1"); assertThat(foundCard.back()).isEqualTo("Back 1"); @@ -289,7 +289,9 @@ void createCard_whenNewCard_savesAndReturnsCard() { when(subjectService.findById(SUBJECT_ID)).thenReturn(subject); - Set decks = Set.of(deck1, deck2); + Set decks = new HashSet<>(); + decks.add(deck1); + decks.add(deck2); when(cardDeckService.getOrCreateDecksByNamesAndSubjectId(anySet(), anyLong())) .thenReturn(decks); @@ -398,7 +400,7 @@ void testUpdateCard_doesNotUpdateDecks_whenSameDecks() { CardRequest.of("Front 1", "Back 1", SUBJECT_ID, deck1.getName(), deck2.getName()); cardService.updateCard(CARD_1_ID, request); - CardResponse updatedCard = cardService.getCardResponseById(CARD_1_ID); + CardSummary updatedCard = cardService.getCardResponseById(CARD_1_ID); assertThat(updatedCard.front()).isEqualTo("Front 1"); assertThat(updatedCard.back()).isEqualTo("Back 1"); assertThat(updatedCard.decks()) diff --git a/backend/src/test/java/com/example/flashcards_backend/service/CsvUploadServiceTest.java b/backend/src/test/java/com/example/flashcards_backend/service/CsvUploadServiceTest.java index 1745cea..cb8500e 100644 --- a/backend/src/test/java/com/example/flashcards_backend/service/CsvUploadServiceTest.java +++ b/backend/src/test/java/com/example/flashcards_backend/service/CsvUploadServiceTest.java @@ -103,7 +103,7 @@ void uploadCsv_filtersInvalidPartitionsDuplicatesAndSavesValid() throws Exceptio .allSatisfy(msg -> assertThat(msg).startsWith("Skipping invalid row:")); // Result DTO matches our mock - List saved = result.saved(); + List saved = result.saved(); assertThat(saved).singleElement().satisfies(card -> { assertThat(card.front()).isEqualTo("f3"); assertThat(card.back()).isEqualTo("b3"); @@ -113,7 +113,7 @@ void uploadCsv_filtersInvalidPartitionsDuplicatesAndSavesValid() throws Exceptio .containsExactlyInAnyOrder(DeckSummary.fromEntity(deck1), DeckSummary.fromEntity(deck2)); }); assertThat(result.duplicates()) - .containsExactly(CardResponse.builder().front("f4").back("b4").build()); + .containsExactly(CardSummary.builder().front("f4").back("b4").build()); // CardService invoked once with the correct request list verify(cardService).createCards(argThat(reqs -> diff --git a/backend/src/test/java/com/example/flashcards_backend/service/UserStatsServiceTest.java b/backend/src/test/java/com/example/flashcards_backend/service/UserStatsServiceTest.java index 184419c..401d52e 100644 --- a/backend/src/test/java/com/example/flashcards_backend/service/UserStatsServiceTest.java +++ b/backend/src/test/java/com/example/flashcards_backend/service/UserStatsServiceTest.java @@ -1,8 +1,9 @@ package com.example.flashcards_backend.service; -import com.example.flashcards_backend.dto.CardResponse; +import com.example.flashcards_backend.dto.CardSummary; import com.example.flashcards_backend.dto.UserStatsResponse; import com.example.flashcards_backend.model.Card; +import com.example.flashcards_backend.model.CardHistory; import com.example.flashcards_backend.model.Subject; import com.example.flashcards_backend.model.User; import com.example.flashcards_backend.repository.CardHistoryRepository; @@ -38,6 +39,8 @@ class UserStatsServiceTest { Card mostViewedCard; User user; Subject subject; + CardHistory hardestCardHistory; + CardHistory mostViewedCardHistory; @BeforeEach void setUp() { @@ -59,6 +62,20 @@ void setUp() { .back("Back 2") .subject(subject) .build(); + hardestCardHistory = CardHistory.builder() + .card(hardestCard) + .avgRating(2.0) + .viewCount(5) + .lastViewed(null) + .lastRating(1) + .build(); + mostViewedCardHistory = CardHistory.builder() + .card(mostViewedCard) + .avgRating(4.5) + .viewCount(50) + .lastViewed(null) + .lastRating(5) + .build(); } @Test @@ -77,11 +94,15 @@ void testGetForUserId_returnsUserStatsResponse() { new Object[] { 0, 30L } ); when(cardHistoryRepository.countByLastRatingForUser(USER_ID)).thenReturn(lastRatingCounts); + when(cardHistoryRepository.findByCardIdAndUserId(hardestCard.getId(), USER_ID)) + .thenReturn(Optional.of(hardestCardHistory)); + when(cardHistoryRepository.findByCardIdAndUserId(mostViewedCard.getId(), USER_ID)) + .thenReturn(Optional.of(mostViewedCardHistory)); UserStatsResponse response = service.getForUserId(USER_ID); - assertThat(response.hardestCard()).isEqualTo(CardResponse.fromEntity(hardestCard)); - assertThat(response.mostViewedCard()).isEqualTo(CardResponse.fromEntity(mostViewedCard)); + assertThat(response.hardestCard()).isEqualTo(CardSummary.fromEntity(hardestCard, hardestCardHistory)); + assertThat(response.mostViewedCard()).isEqualTo(CardSummary.fromEntity(mostViewedCard, mostViewedCardHistory)); assertThat(response.totalCards()).isEqualTo(100L); assertThat(response.totalCardViews()).isEqualTo(250L); assertThat(response.totalLastRating1()).isEqualTo(50L);