feat: add cross-list recent history query and service method

This commit is contained in:
2026-07-06 18:59:32 +02:00
parent b58008c8bc
commit b7e0fa1c8b
4 changed files with 105 additions and 0 deletions
@@ -0,0 +1,12 @@
package de.zendric.app.xpensely_server.model.DTO;
import java.time.LocalDateTime;
public record RecentHistoryEntryDto(
String type,
String actorUsername,
String expenseTitle,
LocalDateTime timestamp,
Long listId,
String listName) {
}
@@ -13,6 +13,9 @@ public interface ExpenseHistoryRepository extends JpaRepository<ExpenseHistoryEn
Page<ExpenseHistoryEntry> findByExpenseListIdOrderByTimestampDescIdDesc(Long expenseListId, Pageable pageable); Page<ExpenseHistoryEntry> findByExpenseListIdOrderByTimestampDescIdDesc(Long expenseListId, Pageable pageable);
Page<ExpenseHistoryEntry> findByExpenseListOwnerIdOrExpenseListSharedWithIdOrderByTimestampDescIdDesc(
Long ownerId, Long sharedWithId, Pageable pageable);
@Query("select h.expenseId from ExpenseHistoryEntry h") @Query("select h.expenseId from ExpenseHistoryEntry h")
List<Long> findAllExpenseIds(); List<Long> findAllExpenseIds();
@@ -17,6 +17,7 @@ import tools.jackson.databind.ObjectMapper;
import de.zendric.app.xpensely_server.model.AppUser; import de.zendric.app.xpensely_server.model.AppUser;
import de.zendric.app.xpensely_server.model.DTO.HistoryEntryDto; import de.zendric.app.xpensely_server.model.DTO.HistoryEntryDto;
import de.zendric.app.xpensely_server.model.DTO.HistoryPageDto; import de.zendric.app.xpensely_server.model.DTO.HistoryPageDto;
import de.zendric.app.xpensely_server.model.DTO.RecentHistoryEntryDto;
import de.zendric.app.xpensely_server.model.Expense; import de.zendric.app.xpensely_server.model.Expense;
import de.zendric.app.xpensely_server.model.ExpenseHistoryEntry; import de.zendric.app.xpensely_server.model.ExpenseHistoryEntry;
import de.zendric.app.xpensely_server.model.ExpenseList; import de.zendric.app.xpensely_server.model.ExpenseList;
@@ -104,6 +105,23 @@ public class HistoryService {
return new HistoryPageDto(entries, result.hasNext()); return new HistoryPageDto(entries, result.hasNext());
} }
@Transactional(readOnly = true)
public List<RecentHistoryEntryDto> getRecentForUser(AppUser user, int limit) {
int cappedLimit = Math.min(Math.max(limit, 1), 20);
Page<ExpenseHistoryEntry> result = historyRepository
.findByExpenseListOwnerIdOrExpenseListSharedWithIdOrderByTimestampDescIdDesc(
user.getId(), user.getId(), PageRequest.of(0, cappedLimit));
return result.getContent().stream()
.map(e -> new RecentHistoryEntryDto(
e.getType().name(),
e.getActor() == null ? null : e.getActor().getUsername(),
e.getExpenseTitle(),
e.getTimestamp(),
e.getExpenseList().getId(),
e.getExpenseList().getName()))
.toList();
}
private HistoryEntryDto toDto(ExpenseHistoryEntry entry) { private HistoryEntryDto toDto(ExpenseHistoryEntry entry) {
List<FieldChange> changes = List.of(); List<FieldChange> changes = List.of();
if (entry.getChanges() != null) { if (entry.getChanges() != null) {
@@ -18,6 +18,7 @@ import tools.jackson.databind.ObjectMapper;
import de.zendric.app.xpensely_server.model.AppUser; import de.zendric.app.xpensely_server.model.AppUser;
import de.zendric.app.xpensely_server.model.DTO.HistoryPageDto; import de.zendric.app.xpensely_server.model.DTO.HistoryPageDto;
import de.zendric.app.xpensely_server.model.DTO.RecentHistoryEntryDto;
import de.zendric.app.xpensely_server.model.Expense; import de.zendric.app.xpensely_server.model.Expense;
import de.zendric.app.xpensely_server.model.ExpenseHistoryEntry; import de.zendric.app.xpensely_server.model.ExpenseHistoryEntry;
import de.zendric.app.xpensely_server.model.ExpenseList; import de.zendric.app.xpensely_server.model.ExpenseList;
@@ -206,4 +207,75 @@ class HistoryServiceTest {
assertThat(page.entries().get(0).changes()).isEmpty(); assertThat(page.entries().get(0).changes()).isEmpty();
} }
private ExpenseHistoryEntry historyEntry(Long id, ExpenseList list, AppUser actor,
HistoryEntryType type, String title, java.time.LocalDateTime timestamp) {
ExpenseHistoryEntry e = new ExpenseHistoryEntry();
e.setId(id);
e.setExpenseList(list);
e.setActor(actor);
e.setType(type);
e.setExpenseTitle(title);
e.setTimestamp(timestamp);
return e;
}
@Test
void getRecentForUser_mapsEntriesWithListIdAndName() {
AppUser alice = user(1L, "alice");
ExpenseList list = new ExpenseList();
list.setId(10L);
list.setName("Trip to Rome");
ExpenseHistoryEntry entry = historyEntry(100L, list, alice,
HistoryEntryType.UPDATED, "Groceries", java.time.LocalDateTime.of(2026, 7, 6, 12, 0));
when(historyRepository
.findByExpenseListOwnerIdOrExpenseListSharedWithIdOrderByTimestampDescIdDesc(
eq(1L), eq(1L), any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of(entry)));
List<RecentHistoryEntryDto> result = service.getRecentForUser(alice, 4);
assertThat(result).containsExactly(new RecentHistoryEntryDto(
"UPDATED", "alice", "Groceries",
java.time.LocalDateTime.of(2026, 7, 6, 12, 0), 10L, "Trip to Rome"));
}
@Test
void getRecentForUser_nullActor_mapsToNullUsername() {
AppUser alice = user(1L, "alice");
ExpenseList list = new ExpenseList();
list.setId(10L);
list.setName("Trip to Rome");
ExpenseHistoryEntry entry = historyEntry(100L, list, null,
HistoryEntryType.CREATED, "Taxi", java.time.LocalDateTime.of(2026, 7, 6, 12, 0));
when(historyRepository
.findByExpenseListOwnerIdOrExpenseListSharedWithIdOrderByTimestampDescIdDesc(
eq(1L), eq(1L), any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of(entry)));
List<RecentHistoryEntryDto> result = service.getRecentForUser(alice, 4);
assertThat(result.get(0).actorUsername()).isNull();
}
@Test
void getRecentForUser_clampsLimitBetween1And20() {
AppUser alice = user(1L, "alice");
when(historyRepository
.findByExpenseListOwnerIdOrExpenseListSharedWithIdOrderByTimestampDescIdDesc(
eq(1L), eq(1L), any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of()));
service.getRecentForUser(alice, 500);
verify(historyRepository)
.findByExpenseListOwnerIdOrExpenseListSharedWithIdOrderByTimestampDescIdDesc(
1L, 1L, PageRequest.of(0, 20));
service.getRecentForUser(alice, 0);
verify(historyRepository)
.findByExpenseListOwnerIdOrExpenseListSharedWithIdOrderByTimestampDescIdDesc(
1L, 1L, PageRequest.of(0, 1));
}
} }