From 2c9b6ea8846eefa68bd39b751c3adecf56917197 Mon Sep 17 00:00:00 2001 From: lucian Date: Fri, 3 Oct 2025 18:18:40 +0100 Subject: [PATCH 1/9] FC-238 removes subjects from User --- .../flashcards_backend/model/User.java | 52 ++---- .../integration/CsvUploadIT.java | 169 ++++++++---------- 2 files changed, 95 insertions(+), 126 deletions(-) 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..5b8c3f8 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,6 +1,5 @@ package com.example.flashcards_backend.model; -import com.fasterxml.jackson.annotation.JsonManagedReference; import jakarta.persistence.*; import lombok.*; @@ -17,43 +16,28 @@ @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; + @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 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 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); - } + @Builder.Default + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) + private List cardHistories = new ArrayList<>(); } 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(); + } } From 33b6f8b7477589941e15787b2fa10dc59810d66a Mon Sep 17 00:00:00 2001 From: lucian Date: Fri, 3 Oct 2025 18:20:35 +0100 Subject: [PATCH 2/9] FC-238 removes cards and decks from User --- .../com/example/flashcards_backend/model/User.java | 14 -------------- 1 file changed, 14 deletions(-) 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 5b8c3f8..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 @@ -3,8 +3,6 @@ import jakarta.persistence.*; import lombok.*; -import java.util.ArrayList; -import java.util.List; import java.util.UUID; @Entity @@ -28,16 +26,4 @@ public class User { @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<>(); } From 65e5e2cd4c385bc0f81a34094b6ae4383be0825e Mon Sep 17 00:00:00 2001 From: lucian Date: Fri, 3 Oct 2025 18:23:05 +0100 Subject: [PATCH 3/9] FC-238 removes cards and decks from Subject --- .../java/com/example/flashcards_backend/dto/UserDto.java | 7 +------ .../com/example/flashcards_backend/model/Subject.java | 8 -------- 2 files changed, 1 insertion(+), 14 deletions(-) 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/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 From 9cedf0ff07a7bfa930051b25e0b2e8bd73305c56 Mon Sep 17 00:00:00 2001 From: lucian Date: Sun, 5 Oct 2025 10:44:25 +0100 Subject: [PATCH 4/9] FC-238 removes card histories from Card some failing tests due to placeholder values in CardSummary need replacing with real ch values --- .../controller/CardController.java | 18 +++++----- .../{CardResponse.java => CardSummary.java} | 27 ++++++++------ .../dto/CsvUploadResponseDto.java | 2 +- .../dto/UserStatsResponse.java | 4 +-- .../flashcards_backend/model/Card.java | 6 ---- .../flashcards_backend/model/CardHistory.java | 7 ---- .../repository/CardRepository.java | 30 +++++++++------- .../service/CardHistoryService.java | 35 ++++++++++--------- .../service/CardService.java | 22 ++++++------ .../service/CsvUploadServiceImpl.java | 12 +++---- .../service/UserStatsService.java | 6 ++-- .../controller/CsvUploadControllerTest.java | 8 ++--- ...ResponseTest.java => CardSummaryTest.java} | 28 +++++++-------- .../service/CardServiceTest.java | 6 ++-- .../service/CsvUploadServiceTest.java | 4 +-- .../service/UserStatsServiceTest.java | 6 ++-- 16 files changed, 110 insertions(+), 111 deletions(-) rename backend/src/main/java/com/example/flashcards_backend/dto/{CardResponse.java => CardSummary.java} (79%) rename backend/src/test/java/com/example/flashcards_backend/dto/{CardResponseTest.java => CardSummaryTest.java} (58%) 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..5e25cfc 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( @@ -129,13 +129,13 @@ public ResponseEntity> createCards( description = "Card not found", content = @Content(mediaType = "application/json")) @PutMapping("/{id}") - public ResponseEntity update( + public ResponseEntity update( @PathVariable Long id, @Valid @RequestBody CardRequest request, @AuthenticationPrincipal Jwt jwt) { currentUserService.getCurrentUser(jwt); - CardResponse cardResponse = cardService.updateCard(id, request); - return ResponseEntity.ok(cardResponse); + CardSummary cardSummary = cardService.updateCard(id, request); + return ResponseEntity.ok(cardSummary); } @Operation(summary = "Rate card", description = "Rates a card by its ID.") @@ -191,10 +191,10 @@ public ResponseEntity deleteCards( description = "Card not found", content = @Content(mediaType = "application/json")) @PatchMapping(value = "/{id}/hints") - public ResponseEntity updateCardHints( + public ResponseEntity updateCardHints( @RequestBody HintRequest request, @PathVariable Long id, @AuthenticationPrincipal Jwt jwt) { currentUserService.getCurrentUser(jwt); - CardResponse response = cardService.setHints(request, id); + CardSummary response = cardService.setHints(request, id); return ResponseEntity.ok(response); } } diff --git a/backend/src/main/java/com/example/flashcards_backend/dto/CardResponse.java b/backend/src/main/java/com/example/flashcards_backend/dto/CardSummary.java similarity index 79% rename from backend/src/main/java/com/example/flashcards_backend/dto/CardResponse.java rename to backend/src/main/java/com/example/flashcards_backend/dto/CardSummary.java index 28508c5..41fc69c 100644 --- a/backend/src/main/java/com/example/flashcards_backend/dto/CardResponse.java +++ b/backend/src/main/java/com/example/flashcards_backend/dto/CardSummary.java @@ -3,6 +3,7 @@ import com.example.flashcards_backend.model.Card; import com.example.flashcards_backend.model.CardHistory; +import java.time.LocalDateTime; import java.util.HashSet; import java.util.Set; import java.util.stream.Collectors; @@ -13,7 +14,7 @@ import lombok.Builder; @Builder -public record CardResponse( +public record CardSummary( @JsonProperty("id") Long id, @JsonProperty("front") String front, @JsonProperty("back") String back, @@ -28,20 +29,24 @@ public record CardResponse( ) { @JsonCreator - public CardResponse { + public CardSummary { // canonical constructor; Jackson will call this } - public static CardResponse fromEntity(Card card) { + public static CardSummary fromEntity(Card card) { Set deckSummaries = card.getDecks().stream() .map(DeckSummary::fromEntity) .collect(Collectors.toSet()); - CardHistory ch = card.getCardHistories().stream() - .findFirst() - .orElseGet(CardHistory::new); + // TODO fetch real CardHistory from DB + CardHistory ch = CardHistory.builder() + .avgRating(420.0) + .viewCount(420) + .lastViewed(LocalDateTime.now()) + .lastRating(420) + .build(); - return new CardResponse( + return new CardSummary( card.getId(), card.getFront(), card.getBack(), @@ -56,8 +61,8 @@ public static CardResponse fromEntity(Card card) { ); } - public static CardResponse fromEntity(CardDeckRowProjection cd) { - return new CardResponse( + public static CardSummary fromEntity(CardDeckRowProjection cd) { + return new CardSummary( cd.getCardId(), cd.getFront(), cd.getBack(), @@ -72,8 +77,8 @@ public static CardResponse fromEntity(CardDeckRowProjection cd) { ); } - public static CardResponse fromEntity(CreateCardResponse ccr) { - return CardResponse.builder() + public static CardSummary fromEntity(CreateCardResponse ccr) { + return CardSummary.builder() .id(ccr.id()) .front(ccr.front()) .back(ccr.back()) 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/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..318211d 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 @@ -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; 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/repository/CardRepository.java b/backend/src/main/java/com/example/flashcards_backend/repository/CardRepository.java index c19a8e3..5919f97 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 @@ -35,7 +35,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,17 +60,23 @@ SELECT COUNT(c) """) long countByUserId(@Param("userId") UUID userId); - Optional findTopByCardHistories_User_IdOrderByCardHistories_AvgRatingDesc(UUID userId); - - default Optional findHardestByUserId(UUID userId) { - return findTopByCardHistories_User_IdOrderByCardHistories_AvgRatingDesc(userId); - } - - Optional findTopByCardHistories_User_IdOrderByCardHistories_ViewCountDesc(UUID userId); - - default Optional findMostViewedByUserId(UUID userId) { - return findTopByCardHistories_User_IdOrderByCardHistories_ViewCountDesc(userId); - } + @Query(""" + SELECT c + FROM Card c + JOIN CardHistory ch ON ch.card = c + WHERE ch.user.id = :userId + ORDER BY ch.avgRating DESC + """) + Optional findHardestByUserId(@Param("userId") 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 + """) + Optional findMostViewedByUserId(@Param("userId") UUID userId); @Modifying @Query(value = "DELETE FROM card_deck WHERE card_id IN :cardIds", nativeQuery = true) 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..de3bdd3 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()) { @@ -115,7 +115,7 @@ public List createCards(@NonNull List requests) } @Transactional - public CardResponse updateCard(Long id, CardRequest request) { + public CardSummary updateCard(Long id, CardRequest request) { // Completely replace the card's front and back text and set its decks to those of the request. log.info("Updating card {}", id); Card card = fetchCardById(id); @@ -136,7 +136,7 @@ public CardResponse updateCard(Long id, CardRequest request) { } cardRepository.saveAndFlush(card); log.info("Card {} successfully updated", id); - return CardResponse.fromEntity(card); + return CardSummary.fromEntity(card); } @Transactional @@ -166,12 +166,12 @@ public void deleteCards(List ids) throws CardNotFoundException { } @Transactional - public CardResponse setHints(HintRequest request, Long id) { + public CardSummary setHints(HintRequest request, Long id) { log.info("Setting hints for card {}", id); Card card = fetchCardById(id); card.setHintFront(Strings.trimToNull(request.hintFront())); card.setHintBack(Strings.trimToNull(request.hintBack())); - return CardResponse.fromEntity(card); + return CardSummary.fromEntity(card); } /* Helpers */ @@ -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..f4cde1f 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,6 +1,6 @@ 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.repository.CardHistoryRepository; @@ -33,8 +33,8 @@ public UserStatsResponse getForUserId(UUID userId) { return UserStatsResponse.builder() .totalCards(totalCards) - .hardestCard(hardestCard.map(CardResponse::fromEntity).orElse(null)) - .mostViewedCard(mostViewedCard.map(CardResponse::fromEntity).orElse(null)) + .hardestCard(hardestCard.map(CardSummary::fromEntity).orElse(null)) + .mostViewedCard(mostViewedCard.map(CardSummary::fromEntity).orElse(null)) .totalCardViews(totalCardViews) .totalLastRating1(countsForEachLastViewedRating.getOrDefault(1, 0L)) .totalLastRating2(countsForEachLastViewedRating.getOrDefault(2, 0L)) 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..daedf76 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); + 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/service/CardServiceTest.java b/backend/src/test/java/com/example/flashcards_backend/service/CardServiceTest.java index 7c1b57e..2deda33 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"); @@ -398,7 +398,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..842da10 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,6 +1,6 @@ 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.Subject; @@ -80,8 +80,8 @@ void testGetForUserId_returnsUserStatsResponse() { 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)); + assertThat(response.mostViewedCard()).isEqualTo(CardSummary.fromEntity(mostViewedCard)); assertThat(response.totalCards()).isEqualTo(100L); assertThat(response.totalCardViews()).isEqualTo(250L); assertThat(response.totalLastRating1()).isEqualTo(50L); From 605821d5272250809d60a70f37a7fd96b903161d Mon Sep 17 00:00:00 2001 From: lucian Date: Sun, 5 Oct 2025 10:47:51 +0100 Subject: [PATCH 5/9] FC-238 removes custom builder from Card --- .../flashcards_backend/model/Card.java | 40 +------------------ .../service/CardServiceTest.java | 4 +- 2 files changed, 4 insertions(+), 40 deletions(-) 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 318211d..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 @@ -112,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/test/java/com/example/flashcards_backend/service/CardServiceTest.java b/backend/src/test/java/com/example/flashcards_backend/service/CardServiceTest.java index 2deda33..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 @@ -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); From b9edabf5102ad0417e20a18c91d65d164630ce91 Mon Sep 17 00:00:00 2001 From: lucian Date: Sun, 5 Oct 2025 10:53:47 +0100 Subject: [PATCH 6/9] FC-238 adds CardResponse for card service methods (no ch props) --- .../controller/CardController.java | 10 +++--- .../flashcards_backend/dto/CardResponse.java | 31 +++++++++++++++++++ .../service/CardService.java | 8 ++--- 3 files changed, 40 insertions(+), 9 deletions(-) create mode 100644 backend/src/main/java/com/example/flashcards_backend/dto/CardResponse.java 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 5e25cfc..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 @@ -129,13 +129,13 @@ public ResponseEntity> createCards( description = "Card not found", content = @Content(mediaType = "application/json")) @PutMapping("/{id}") - public ResponseEntity update( + public ResponseEntity update( @PathVariable Long id, @Valid @RequestBody CardRequest request, @AuthenticationPrincipal Jwt jwt) { currentUserService.getCurrentUser(jwt); - CardSummary cardSummary = cardService.updateCard(id, request); - return ResponseEntity.ok(cardSummary); + CardResponse response = cardService.updateCard(id, request); + return ResponseEntity.ok(response); } @Operation(summary = "Rate card", description = "Rates a card by its ID.") @@ -191,10 +191,10 @@ public ResponseEntity deleteCards( description = "Card not found", content = @Content(mediaType = "application/json")) @PatchMapping(value = "/{id}/hints") - public ResponseEntity updateCardHints( + public ResponseEntity updateCardHints( @RequestBody HintRequest request, @PathVariable Long id, @AuthenticationPrincipal Jwt jwt) { currentUserService.getCurrentUser(jwt); - CardSummary response = cardService.setHints(request, id); + CardResponse response = cardService.setHints(request, id); return ResponseEntity.ok(response); } } 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 new file mode 100644 index 0000000..077aa0f --- /dev/null +++ b/backend/src/main/java/com/example/flashcards_backend/dto/CardResponse.java @@ -0,0 +1,31 @@ +package com.example.flashcards_backend.dto; + +import com.example.flashcards_backend.model.Card; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.Set; +import java.util.stream.Collectors; + +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("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/service/CardService.java b/backend/src/main/java/com/example/flashcards_backend/service/CardService.java index de3bdd3..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 @@ -115,7 +115,7 @@ public List createCards(@NonNull List requests) } @Transactional - public CardSummary updateCard(Long id, CardRequest request) { + public CardResponse updateCard(Long id, CardRequest request) { // Completely replace the card's front and back text and set its decks to those of the request. log.info("Updating card {}", id); Card card = fetchCardById(id); @@ -136,7 +136,7 @@ public CardSummary updateCard(Long id, CardRequest request) { } cardRepository.saveAndFlush(card); log.info("Card {} successfully updated", id); - return CardSummary.fromEntity(card); + return CardResponse.fromEntity(card); } @Transactional @@ -166,12 +166,12 @@ public void deleteCards(List ids) throws CardNotFoundException { } @Transactional - public CardSummary setHints(HintRequest request, Long id) { + public CardResponse setHints(HintRequest request, Long id) { log.info("Setting hints for card {}", id); Card card = fetchCardById(id); card.setHintFront(Strings.trimToNull(request.hintFront())); card.setHintBack(Strings.trimToNull(request.hintBack())); - return CardSummary.fromEntity(card); + return CardResponse.fromEntity(card); } /* Helpers */ From 3c9e45b959b5b58324a35d317a97cf1fe3873de9 Mon Sep 17 00:00:00 2001 From: lucian Date: Sun, 5 Oct 2025 11:08:38 +0100 Subject: [PATCH 7/9] FC-238 adjusts CardSummary.fromEntity to have second param ch --- .../flashcards_backend/dto/CardSummary.java | 127 ++++++++---------- .../service/UserStatsService.java | 84 ++++++------ .../dto/CardSummaryTest.java | 2 +- .../service/UserStatsServiceTest.java | 25 +++- 4 files changed, 129 insertions(+), 109 deletions(-) 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 index 41fc69c..c7d3484 100644 --- a/backend/src/main/java/com/example/flashcards_backend/dto/CardSummary.java +++ b/backend/src/main/java/com/example/flashcards_backend/dto/CardSummary.java @@ -3,7 +3,6 @@ import com.example.flashcards_backend.model.Card; import com.example.flashcards_backend.model.CardHistory; -import java.time.LocalDateTime; import java.util.HashSet; import java.util.Set; import java.util.stream.Collectors; @@ -15,76 +14,68 @@ @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 -) { + @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) { - Set deckSummaries = card.getDecks().stream() - .map(DeckSummary::fromEntity) - .collect(Collectors.toSet()); + @JsonCreator + public CardSummary { + // canonical constructor; Jackson will call this + } - // TODO fetch real CardHistory from DB - CardHistory ch = CardHistory.builder() - .avgRating(420.0) - .viewCount(420) - .lastViewed(LocalDateTime.now()) - .lastRating(420) - .build(); + public static CardSummary fromEntity(Card card, CardHistory ch) { + Set deckSummaries = + card.getDecks().stream().map(DeckSummary::fromEntity).collect(Collectors.toSet()); - 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() - ); + if (ch == null) { + ch = CardHistory.builder().avgRating(0.0).viewCount(0).lastViewed(null).lastRating(0).build(); } - 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() - ); - } + 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(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(); - } -} \ No newline at end of file + 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/service/UserStatsService.java b/backend/src/main/java/com/example/flashcards_backend/service/UserStatsService.java index f4cde1f..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 @@ -3,53 +3,61 @@ 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(CardSummary::fromEntity).orElse(null)) - .mostViewedCard(mostViewedCard.map(CardSummary::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/dto/CardSummaryTest.java b/backend/src/test/java/com/example/flashcards_backend/dto/CardSummaryTest.java index daedf76..afad857 100644 --- a/backend/src/test/java/com/example/flashcards_backend/dto/CardSummaryTest.java +++ b/backend/src/test/java/com/example/flashcards_backend/dto/CardSummaryTest.java @@ -46,7 +46,7 @@ void testCardResponseFromEntity() { .build(); cardHistory.setCard(card); - CardSummary cardSummary = CardSummary.fromEntity(card); + CardSummary cardSummary = CardSummary.fromEntity(card, cardHistory); assertThat(cardSummary.id()).isEqualTo(2L); assertThat(cardSummary.front()).isEqualTo("Card Front"); assertThat(cardSummary.back()).isEqualTo("Card Back"); 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 842da10..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 @@ -3,6 +3,7 @@ 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(CardSummary.fromEntity(hardestCard)); - assertThat(response.mostViewedCard()).isEqualTo(CardSummary.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); From 811c9d9b86b13ae27947750d5880ded9e36b0265 Mon Sep 17 00:00:00 2001 From: lucian Date: Sun, 5 Oct 2025 11:37:57 +0100 Subject: [PATCH 8/9] FC-238 fixes user stats repo queries to return top 1 --- .../repository/CardRepository.java | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) 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 5919f97..c7420e4 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; @@ -67,7 +69,11 @@ SELECT COUNT(c) WHERE ch.user.id = :userId ORDER BY ch.avgRating DESC """) - Optional findHardestByUserId(@Param("userId") UUID userId); + List findHardestByUserIdInternal(@Param("userId") UUID userId, Pageable pageable); + + default Optional findHardestByUserId(UUID userId) { + return findHardestByUserIdInternal(userId, PageRequest.of(0, 1)).stream().findFirst(); + } @Query(""" SELECT c @@ -76,7 +82,11 @@ SELECT COUNT(c) WHERE ch.user.id = :userId ORDER BY ch.viewCount DESC """) - Optional findMostViewedByUserId(@Param("userId") UUID userId); + List findMostViewedByUserIdInternal(@Param("userId") UUID userId, Pageable pageable); + + default Optional findMostViewedByUserId(UUID userId) { + return findMostViewedByUserIdInternal(userId, PageRequest.of(0, 1)).stream().findFirst(); + } @Modifying @Query(value = "DELETE FROM card_deck WHERE card_id IN :cardIds", nativeQuery = true) From 39c31cc350ba56b3c209df014c13c348bf21ad87 Mon Sep 17 00:00:00 2001 From: lucian Date: Sun, 5 Oct 2025 11:47:43 +0100 Subject: [PATCH 9/9] FC-238 enforces ordering for export rows repo queries --- .../flashcards_backend/repository/CardRepository.java | 10 +++++++--- .../controller/CsvExportControllerTest.java | 3 ++- 2 files changed, 9 insertions(+), 4 deletions(-) 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 c7420e4..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 @@ -62,7 +62,8 @@ SELECT COUNT(c) """) long countByUserId(@Param("userId") UUID userId); - @Query(""" + @Query( + """ SELECT c FROM Card c JOIN CardHistory ch ON ch.card = c @@ -75,7 +76,8 @@ default Optional findHardestByUserId(UUID userId) { return findHardestByUserIdInternal(userId, PageRequest.of(0, 1)).stream().findFirst(); } - @Query(""" + @Query( + """ SELECT c FROM Card c JOIN CardHistory ch ON ch.card = c @@ -122,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); @@ -136,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/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));