Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
import backend.fullstack.config.JwtPrincipal;
import backend.fullstack.temperature.api.dto.TemperatureReadingRequest;
import backend.fullstack.temperature.api.dto.TemperatureReadingResponse;
import backend.fullstack.temperature.api.dto.TemperatureReadingStatsGroupBy;
import backend.fullstack.temperature.api.dto.TemperatureReadingStatsResponse;
import backend.fullstack.temperature.application.TemperatureReadingService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
Expand Down Expand Up @@ -91,6 +93,32 @@ public ResponseEntity<ApiResponse<Page<TemperatureReadingResponse>>> getReadings
return ResponseEntity.ok(ApiResponse.success("Readings retrieved", readings));
}

@GetMapping("/readings/stats")
@PreAuthorize("hasAnyRole('ADMIN','MANAGER')")
@Operation(summary = "Get reading statistics for charts")
@ApiResponses(value = {
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Reading statistics retrieved"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "Unauthorized"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Forbidden")
})
public ResponseEntity<ApiResponse<TemperatureReadingStatsResponse>> getReadingStats(
@AuthenticationPrincipal JwtPrincipal principal,
@RequestParam(required = false) List<Long> unitIds,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime from,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime to,
@RequestParam(defaultValue = "DAY") TemperatureReadingStatsGroupBy groupBy
) {
TemperatureReadingStatsResponse response = readingService.getReadingStats(
principal.organizationId(),
unitIds,
from,
to,
groupBy
);

return ResponseEntity.ok(ApiResponse.success("Reading statistics retrieved", response));
}

@PostMapping("/units/{unitId}/readings")
@PreAuthorize("hasAnyRole('ADMIN','MANAGER','STAFF','SUPERVISOR')")
@Operation(summary = "Register temperature reading")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package backend.fullstack.temperature.api.dto;

import java.time.LocalDateTime;

public record TemperatureReadingDeviationResponse(
Long id,
Long unitId,
String unitName,
Double temperature,
Double threshold,
LocalDateTime timestamp
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package backend.fullstack.temperature.api.dto;

public enum TemperatureReadingStatsGroupBy {
HOUR,
DAY,
WEEK
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package backend.fullstack.temperature.api.dto;

import java.time.LocalDateTime;

public record TemperatureReadingStatsPointResponse(
LocalDateTime timestamp,
Double avgTemperature,
boolean isDeviation
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package backend.fullstack.temperature.api.dto;

import java.util.List;

public record TemperatureReadingStatsResponse(
List<TemperatureReadingStatsSeriesResponse> series,
List<TemperatureReadingDeviationResponse> deviations
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package backend.fullstack.temperature.api.dto;

import java.util.List;

public record TemperatureReadingStatsSeriesResponse(
Long unitId,
String unitName,
List<TemperatureReadingStatsPointResponse> dataPoints
) {
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
package backend.fullstack.temperature.application;

import java.time.LocalDateTime;
import java.time.temporal.TemporalAdjusters;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
Expand All @@ -12,9 +16,14 @@
import backend.fullstack.exceptions.ResourceNotFoundException;
import backend.fullstack.exceptions.UnitInactiveException;
import backend.fullstack.exceptions.UnitNotFoundException;
import backend.fullstack.temperature.api.dto.TemperatureReadingDeviationResponse;
import backend.fullstack.temperature.api.dto.TemperatureReadingMapper;
import backend.fullstack.temperature.api.dto.TemperatureReadingRequest;
import backend.fullstack.temperature.api.dto.TemperatureReadingResponse;
import backend.fullstack.temperature.api.dto.TemperatureReadingStatsGroupBy;
import backend.fullstack.temperature.api.dto.TemperatureReadingStatsPointResponse;
import backend.fullstack.temperature.api.dto.TemperatureReadingStatsResponse;
import backend.fullstack.temperature.api.dto.TemperatureReadingStatsSeriesResponse;
import backend.fullstack.temperature.domain.TemperatureReading;
import backend.fullstack.temperature.infrastructure.TemperatureReadingRepository;
import backend.fullstack.units.domain.TemperatureUnit;
Expand Down Expand Up @@ -69,6 +78,36 @@ public Page<TemperatureReadingResponse> listReadings(
.map(readingMapper::toResponse);
}

@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);

Comment on lines +89 to +93
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
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);
}
Comment on lines +81 to +109
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
@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);
}

Copilot uses AI. Check for mistakes.

public TemperatureReadingResponse createReading(
JwtPrincipal principal,
Long unitId,
Expand Down Expand Up @@ -115,4 +154,90 @@ private User findScopedUser(Long userId, Long organizationId) {

return user;
}

private List<Long> sanitizeUnitIds(List<Long> unitIds) {
if (unitIds == null || unitIds.isEmpty()) {
return null;
}

List<Long> sanitized = unitIds.stream()
.filter(java.util.Objects::nonNull)
.distinct()
.toList();

return sanitized.isEmpty() ? null : sanitized;
}

private TemperatureReadingStatsSeriesResponse toSeries(
List<TemperatureReading> unitReadings,
TemperatureReadingStatsGroupBy groupBy
) {
TemperatureReading firstReading = unitReadings.get(0);
Map<LocalDateTime, List<TemperatureReading>> groupedByTime = unitReadings.stream()
.collect(Collectors.groupingBy(
reading -> truncateTimestamp(reading.getRecordedAt(), groupBy),
java.util.TreeMap::new,
Collectors.toList()
));

List<TemperatureReadingStatsPointResponse> dataPoints = groupedByTime.entrySet().stream()
.map(entry -> {
double avg = entry.getValue().stream()
.map(TemperatureReading::getTemperature)
.filter(java.util.Objects::nonNull)
.mapToDouble(Double::doubleValue)
.average()
.orElse(0.0d);

boolean hasDeviation = entry.getValue().stream().anyMatch(TemperatureReading::isDeviation);
return new TemperatureReadingStatsPointResponse(entry.getKey(), avg, hasDeviation);
})
.toList();

return new TemperatureReadingStatsSeriesResponse(
firstReading.getUnit().getId(),
firstReading.getUnit().getName(),
dataPoints
);
}

private TemperatureReadingDeviationResponse toDeviationResponse(TemperatureReading reading) {
return new TemperatureReadingDeviationResponse(
reading.getId(),
reading.getUnit().getId(),
reading.getUnit().getName(),
reading.getTemperature(),
resolveBreachedThreshold(reading),
reading.getRecordedAt()
);
}

private Double resolveBreachedThreshold(TemperatureReading reading) {
if (reading.getUnit() == null || reading.getTemperature() == null) {
return null;
}

Double minThreshold = reading.getUnit().getMinThreshold();
Double maxThreshold = reading.getUnit().getMaxThreshold();

if (minThreshold != null && reading.getTemperature() < minThreshold) {
return minThreshold;
}

if (maxThreshold != null && reading.getTemperature() > maxThreshold) {
return maxThreshold;
}

return null;
}

private LocalDateTime truncateTimestamp(LocalDateTime timestamp, TemperatureReadingStatsGroupBy groupBy) {
return switch (groupBy) {
case HOUR -> timestamp.withMinute(0).withSecond(0).withNano(0);
case DAY -> timestamp.toLocalDate().atStartOfDay();
case WEEK -> timestamp.toLocalDate()
.with(TemporalAdjusters.previousOrSame(java.time.DayOfWeek.MONDAY))
.atStartOfDay();
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,36 @@ List<TemperatureReading> findByOrganizationAndUnitAndRecordedAtBetween(
@Param("to") LocalDateTime to
);

@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
);
Comment on lines +58 to +70
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.

@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
);
Comment on lines +72 to +86
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.

long countByOrganization_IdAndIsDeviationTrue(Long organizationId);

long countByOrganization_IdAndIsDeviationTrueAndRecordedAtAfter(Long organizationId, LocalDateTime since);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ void globalReadingsRequireAdminOrManager() {
assertPreAuthorize("getReadings", "hasAnyRole('ADMIN','MANAGER')");
}

@Test
void readingStatsRequireAdminOrManager() {
assertPreAuthorize("getReadingStats", "hasAnyRole('ADMIN','MANAGER')");
}

@Test
void createReadingAllowsAllOperationalRoles() {
assertPreAuthorize("createReading", "hasAnyRole('ADMIN','MANAGER','STAFF','SUPERVISOR')");
Expand Down
Loading
Loading