Conversation
Backend Test Results132 tests 131 ✅ 7s ⏱️ For more details on these failures, see this check. Results for commit 1261532. |
There was a problem hiding this comment.
Pull request overview
Adds a new backend API for temperature reading statistics to support charting/analytics, including grouped time-series points per unit and a companion list of deviation events.
Changes:
- Introduces
/api/readings/statsendpoint (ADMIN/MANAGER) returning series + deviations payload. - Implements stats aggregation logic in
TemperatureReadingServiceand adds repository queries to fetch readings for stats. - Adds DTOs and tests covering service aggregation, controller JSON contract, and security annotations.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| backend/src/main/java/backend/fullstack/temperature/api/TemperatureReadingController.java | Adds GET /readings/stats endpoint with query params and response wrapper. |
| backend/src/main/java/backend/fullstack/temperature/application/TemperatureReadingService.java | Implements getReadingStats aggregation, deviation mapping, and time bucketing. |
| backend/src/main/java/backend/fullstack/temperature/infrastructure/TemperatureReadingRepository.java | Adds JPQL queries to fetch readings for stats (with/without unit filters). |
| backend/src/main/java/backend/fullstack/temperature/api/dto/TemperatureReadingDeviationResponse.java | New DTO representing deviation event entries. |
| backend/src/main/java/backend/fullstack/temperature/api/dto/TemperatureReadingStatsGroupBy.java | New enum for time grouping resolution. |
| backend/src/main/java/backend/fullstack/temperature/api/dto/TemperatureReadingStatsPointResponse.java | New DTO for averaged stats points. |
| backend/src/main/java/backend/fullstack/temperature/api/dto/TemperatureReadingStatsResponse.java | New top-level stats response DTO (series + deviations). |
| backend/src/main/java/backend/fullstack/temperature/api/dto/TemperatureReadingStatsSeriesResponse.java | New DTO representing per-unit time series. |
| backend/src/test/java/backend/fullstack/temperature/application/TemperatureReadingServiceTest.java | Adds tests for stats aggregation behavior and unitId sanitization behavior. |
| backend/src/test/java/backend/fullstack/temperature/api/TemperatureReadingControllerTest.java | Adds MockMvc contract tests for /api/readings/stats param parsing and JSON structure. |
| backend/src/test/java/backend/fullstack/temperature/api/TemperatureReadingControllerSecurityAnnotationsTest.java | Adds @PreAuthorize assertion for the new controller method. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| @Query(""" | ||
| SELECT r | ||
| FROM TemperatureReading r | ||
| WHERE r.organization.id = :organizationId | ||
| AND (:from IS NULL OR r.recordedAt >= :from) | ||
| AND (:to IS NULL OR r.recordedAt <= :to) | ||
| ORDER BY r.unit.name ASC, r.recordedAt ASC | ||
| """) | ||
| List<TemperatureReading> findForStatsByOrganizationAndRange( | ||
| @Param("organizationId") Long organizationId, | ||
| @Param("from") LocalDateTime from, | ||
| @Param("to") LocalDateTime to | ||
| ); |
There was a problem hiding this comment.
TemperatureReading.unit is FetchType.LAZY, but this stats query returns TemperatureReading entities without fetching unit. Since getReadingStats() immediately accesses r.unit.id/name and thresholds, this will trigger N+1 selects. Consider changing the query to JOIN FETCH r.unit (or using a DTO/projection query) to load unit data in the initial query.
| @Query(""" | ||
| SELECT r | ||
| FROM TemperatureReading r | ||
| WHERE r.organization.id = :organizationId | ||
| AND r.unit.id IN :unitIds | ||
| AND (:from IS NULL OR r.recordedAt >= :from) | ||
| AND (:to IS NULL OR r.recordedAt <= :to) | ||
| ORDER BY r.unit.name ASC, r.recordedAt ASC | ||
| """) | ||
| List<TemperatureReading> findForStatsByOrganizationAndUnitIdsAndRange( | ||
| @Param("organizationId") Long organizationId, | ||
| @Param("unitIds") List<Long> unitIds, | ||
| @Param("from") LocalDateTime from, | ||
| @Param("to") LocalDateTime to | ||
| ); |
There was a problem hiding this comment.
Same N+1 concern as the unfiltered stats query: this query returns readings with unit still lazy, but the service reads unit.id/name and thresholds for every row. Prefer JOIN FETCH r.unit (and possibly reference the join alias in ORDER BY) or switch to a projection to avoid extra queries per unit/reading.
| @Transactional(readOnly = true) | ||
| public TemperatureReadingStatsResponse getReadingStats( | ||
| Long organizationId, | ||
| List<Long> unitIds, | ||
| LocalDateTime from, | ||
| LocalDateTime to, | ||
| TemperatureReadingStatsGroupBy groupBy | ||
| ) { | ||
| List<Long> distinctUnitIds = sanitizeUnitIds(unitIds); | ||
| List<TemperatureReading> readings = distinctUnitIds == null | ||
| ? readingRepository.findForStatsByOrganizationAndRange(organizationId, from, to) | ||
| : readingRepository.findForStatsByOrganizationAndUnitIdsAndRange(organizationId, distinctUnitIds, from, to); | ||
|
|
||
| Map<Long, List<TemperatureReading>> readingsByUnit = readings.stream() | ||
| .collect(Collectors.groupingBy(reading -> reading.getUnit().getId())); | ||
|
|
||
| List<TemperatureReadingStatsSeriesResponse> series = readingsByUnit.values().stream() | ||
| .sorted(Comparator.comparing(unitReadings -> unitReadings.get(0).getUnit().getName(), String.CASE_INSENSITIVE_ORDER)) | ||
| .map(unitReadings -> toSeries(unitReadings, groupBy)) | ||
| .toList(); | ||
|
|
||
| List<TemperatureReadingDeviationResponse> deviations = readings.stream() | ||
| .filter(TemperatureReading::isDeviation) | ||
| .sorted(Comparator.comparing(TemperatureReading::getRecordedAt).reversed()) | ||
| .map(this::toDeviationResponse) | ||
| .toList(); | ||
|
|
||
| return new TemperatureReadingStatsResponse(series, deviations); | ||
| } |
There was a problem hiding this comment.
The new getReadingStats method is indented differently than the rest of the class (annotation, method signature, and closing brace). This hurts readability and may trip formatting/lint rules; align it to the same indentation level as listReadings/createReading.
| @Transactional(readOnly = true) | |
| public TemperatureReadingStatsResponse getReadingStats( | |
| Long organizationId, | |
| List<Long> unitIds, | |
| LocalDateTime from, | |
| LocalDateTime to, | |
| TemperatureReadingStatsGroupBy groupBy | |
| ) { | |
| List<Long> distinctUnitIds = sanitizeUnitIds(unitIds); | |
| List<TemperatureReading> readings = distinctUnitIds == null | |
| ? readingRepository.findForStatsByOrganizationAndRange(organizationId, from, to) | |
| : readingRepository.findForStatsByOrganizationAndUnitIdsAndRange(organizationId, distinctUnitIds, from, to); | |
| Map<Long, List<TemperatureReading>> readingsByUnit = readings.stream() | |
| .collect(Collectors.groupingBy(reading -> reading.getUnit().getId())); | |
| List<TemperatureReadingStatsSeriesResponse> series = readingsByUnit.values().stream() | |
| .sorted(Comparator.comparing(unitReadings -> unitReadings.get(0).getUnit().getName(), String.CASE_INSENSITIVE_ORDER)) | |
| .map(unitReadings -> toSeries(unitReadings, groupBy)) | |
| .toList(); | |
| List<TemperatureReadingDeviationResponse> deviations = readings.stream() | |
| .filter(TemperatureReading::isDeviation) | |
| .sorted(Comparator.comparing(TemperatureReading::getRecordedAt).reversed()) | |
| .map(this::toDeviationResponse) | |
| .toList(); | |
| return new TemperatureReadingStatsResponse(series, deviations); | |
| } | |
| @Transactional(readOnly = true) | |
| public TemperatureReadingStatsResponse getReadingStats( | |
| Long organizationId, | |
| List<Long> unitIds, | |
| LocalDateTime from, | |
| LocalDateTime to, | |
| TemperatureReadingStatsGroupBy groupBy | |
| ) { | |
| List<Long> distinctUnitIds = sanitizeUnitIds(unitIds); | |
| List<TemperatureReading> readings = distinctUnitIds == null | |
| ? readingRepository.findForStatsByOrganizationAndRange(organizationId, from, to) | |
| : readingRepository.findForStatsByOrganizationAndUnitIdsAndRange(organizationId, distinctUnitIds, from, to); | |
| Map<Long, List<TemperatureReading>> readingsByUnit = readings.stream() | |
| .collect(Collectors.groupingBy(reading -> reading.getUnit().getId())); | |
| List<TemperatureReadingStatsSeriesResponse> series = readingsByUnit.values().stream() | |
| .sorted(Comparator.comparing(unitReadings -> unitReadings.get(0).getUnit().getName(), String.CASE_INSENSITIVE_ORDER)) | |
| .map(unitReadings -> toSeries(unitReadings, groupBy)) | |
| .toList(); | |
| List<TemperatureReadingDeviationResponse> deviations = readings.stream() | |
| .filter(TemperatureReading::isDeviation) | |
| .sorted(Comparator.comparing(TemperatureReading::getRecordedAt).reversed()) | |
| .map(this::toDeviationResponse) | |
| .toList(); | |
| return new TemperatureReadingStatsResponse(series, deviations); | |
| } |
| List<Long> distinctUnitIds = sanitizeUnitIds(unitIds); | ||
| List<TemperatureReading> readings = distinctUnitIds == null | ||
| ? readingRepository.findForStatsByOrganizationAndRange(organizationId, from, to) | ||
| : readingRepository.findForStatsByOrganizationAndUnitIdsAndRange(organizationId, distinctUnitIds, from, to); | ||
|
|
There was a problem hiding this comment.
getReadingStats fetches all readings in the requested range and aggregates them in-memory. For large ranges/orgs this can become slow and memory-heavy; consider moving bucketing/AVG aggregation to the DB (and fetching deviations via a separate bounded query) to keep response time predictable.
No description provided.