Skip to content

Commit 47f4e80

Browse files
authored
Merge pull request #32 from Stcwal/backend/readings
Backend/readings
2 parents bc8bba4 + 1261532 commit 47f4e80

11 files changed

+487
-0
lines changed

backend/src/main/java/backend/fullstack/temperature/api/TemperatureReadingController.java

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
import backend.fullstack.config.JwtPrincipal;
2424
import backend.fullstack.temperature.api.dto.TemperatureReadingRequest;
2525
import backend.fullstack.temperature.api.dto.TemperatureReadingResponse;
26+
import backend.fullstack.temperature.api.dto.TemperatureReadingStatsGroupBy;
27+
import backend.fullstack.temperature.api.dto.TemperatureReadingStatsResponse;
2628
import backend.fullstack.temperature.application.TemperatureReadingService;
2729
import io.swagger.v3.oas.annotations.Operation;
2830
import io.swagger.v3.oas.annotations.responses.ApiResponses;
@@ -91,6 +93,32 @@ public ResponseEntity<ApiResponse<Page<TemperatureReadingResponse>>> getReadings
9193
return ResponseEntity.ok(ApiResponse.success("Readings retrieved", readings));
9294
}
9395

96+
@GetMapping("/readings/stats")
97+
@PreAuthorize("hasAnyRole('ADMIN','MANAGER')")
98+
@Operation(summary = "Get reading statistics for charts")
99+
@ApiResponses(value = {
100+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Reading statistics retrieved"),
101+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "Unauthorized"),
102+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Forbidden")
103+
})
104+
public ResponseEntity<ApiResponse<TemperatureReadingStatsResponse>> getReadingStats(
105+
@AuthenticationPrincipal JwtPrincipal principal,
106+
@RequestParam(required = false) List<Long> unitIds,
107+
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime from,
108+
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime to,
109+
@RequestParam(defaultValue = "DAY") TemperatureReadingStatsGroupBy groupBy
110+
) {
111+
TemperatureReadingStatsResponse response = readingService.getReadingStats(
112+
principal.organizationId(),
113+
unitIds,
114+
from,
115+
to,
116+
groupBy
117+
);
118+
119+
return ResponseEntity.ok(ApiResponse.success("Reading statistics retrieved", response));
120+
}
121+
94122
@PostMapping("/units/{unitId}/readings")
95123
@PreAuthorize("hasAnyRole('ADMIN','MANAGER','STAFF','SUPERVISOR')")
96124
@Operation(summary = "Register temperature reading")
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package backend.fullstack.temperature.api.dto;
2+
3+
import java.time.LocalDateTime;
4+
5+
public record TemperatureReadingDeviationResponse(
6+
Long id,
7+
Long unitId,
8+
String unitName,
9+
Double temperature,
10+
Double threshold,
11+
LocalDateTime timestamp
12+
) {
13+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package backend.fullstack.temperature.api.dto;
2+
3+
public enum TemperatureReadingStatsGroupBy {
4+
HOUR,
5+
DAY,
6+
WEEK
7+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package backend.fullstack.temperature.api.dto;
2+
3+
import java.time.LocalDateTime;
4+
5+
public record TemperatureReadingStatsPointResponse(
6+
LocalDateTime timestamp,
7+
Double avgTemperature,
8+
boolean isDeviation
9+
) {
10+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package backend.fullstack.temperature.api.dto;
2+
3+
import java.util.List;
4+
5+
public record TemperatureReadingStatsResponse(
6+
List<TemperatureReadingStatsSeriesResponse> series,
7+
List<TemperatureReadingDeviationResponse> deviations
8+
) {
9+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package backend.fullstack.temperature.api.dto;
2+
3+
import java.util.List;
4+
5+
public record TemperatureReadingStatsSeriesResponse(
6+
Long unitId,
7+
String unitName,
8+
List<TemperatureReadingStatsPointResponse> dataPoints
9+
) {
10+
}

backend/src/main/java/backend/fullstack/temperature/application/TemperatureReadingService.java

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
package backend.fullstack.temperature.application;
22

33
import java.time.LocalDateTime;
4+
import java.time.temporal.TemporalAdjusters;
5+
import java.util.Comparator;
46
import java.util.List;
7+
import java.util.Map;
8+
import java.util.stream.Collectors;
59

610
import org.springframework.data.domain.Page;
711
import org.springframework.data.domain.Pageable;
@@ -12,9 +16,14 @@
1216
import backend.fullstack.exceptions.ResourceNotFoundException;
1317
import backend.fullstack.exceptions.UnitInactiveException;
1418
import backend.fullstack.exceptions.UnitNotFoundException;
19+
import backend.fullstack.temperature.api.dto.TemperatureReadingDeviationResponse;
1520
import backend.fullstack.temperature.api.dto.TemperatureReadingMapper;
1621
import backend.fullstack.temperature.api.dto.TemperatureReadingRequest;
1722
import backend.fullstack.temperature.api.dto.TemperatureReadingResponse;
23+
import backend.fullstack.temperature.api.dto.TemperatureReadingStatsGroupBy;
24+
import backend.fullstack.temperature.api.dto.TemperatureReadingStatsPointResponse;
25+
import backend.fullstack.temperature.api.dto.TemperatureReadingStatsResponse;
26+
import backend.fullstack.temperature.api.dto.TemperatureReadingStatsSeriesResponse;
1827
import backend.fullstack.temperature.domain.TemperatureReading;
1928
import backend.fullstack.temperature.infrastructure.TemperatureReadingRepository;
2029
import backend.fullstack.units.domain.TemperatureUnit;
@@ -69,6 +78,36 @@ public Page<TemperatureReadingResponse> listReadings(
6978
.map(readingMapper::toResponse);
7079
}
7180

81+
@Transactional(readOnly = true)
82+
public TemperatureReadingStatsResponse getReadingStats(
83+
Long organizationId,
84+
List<Long> unitIds,
85+
LocalDateTime from,
86+
LocalDateTime to,
87+
TemperatureReadingStatsGroupBy groupBy
88+
) {
89+
List<Long> distinctUnitIds = sanitizeUnitIds(unitIds);
90+
List<TemperatureReading> readings = distinctUnitIds == null
91+
? readingRepository.findForStatsByOrganizationAndRange(organizationId, from, to)
92+
: readingRepository.findForStatsByOrganizationAndUnitIdsAndRange(organizationId, distinctUnitIds, from, to);
93+
94+
Map<Long, List<TemperatureReading>> readingsByUnit = readings.stream()
95+
.collect(Collectors.groupingBy(reading -> reading.getUnit().getId()));
96+
97+
List<TemperatureReadingStatsSeriesResponse> series = readingsByUnit.values().stream()
98+
.sorted(Comparator.comparing(unitReadings -> unitReadings.get(0).getUnit().getName(), String.CASE_INSENSITIVE_ORDER))
99+
.map(unitReadings -> toSeries(unitReadings, groupBy))
100+
.toList();
101+
102+
List<TemperatureReadingDeviationResponse> deviations = readings.stream()
103+
.filter(TemperatureReading::isDeviation)
104+
.sorted(Comparator.comparing(TemperatureReading::getRecordedAt).reversed())
105+
.map(this::toDeviationResponse)
106+
.toList();
107+
108+
return new TemperatureReadingStatsResponse(series, deviations);
109+
}
110+
72111
public TemperatureReadingResponse createReading(
73112
JwtPrincipal principal,
74113
Long unitId,
@@ -115,4 +154,90 @@ private User findScopedUser(Long userId, Long organizationId) {
115154

116155
return user;
117156
}
157+
158+
private List<Long> sanitizeUnitIds(List<Long> unitIds) {
159+
if (unitIds == null || unitIds.isEmpty()) {
160+
return null;
161+
}
162+
163+
List<Long> sanitized = unitIds.stream()
164+
.filter(java.util.Objects::nonNull)
165+
.distinct()
166+
.toList();
167+
168+
return sanitized.isEmpty() ? null : sanitized;
169+
}
170+
171+
private TemperatureReadingStatsSeriesResponse toSeries(
172+
List<TemperatureReading> unitReadings,
173+
TemperatureReadingStatsGroupBy groupBy
174+
) {
175+
TemperatureReading firstReading = unitReadings.get(0);
176+
Map<LocalDateTime, List<TemperatureReading>> groupedByTime = unitReadings.stream()
177+
.collect(Collectors.groupingBy(
178+
reading -> truncateTimestamp(reading.getRecordedAt(), groupBy),
179+
java.util.TreeMap::new,
180+
Collectors.toList()
181+
));
182+
183+
List<TemperatureReadingStatsPointResponse> dataPoints = groupedByTime.entrySet().stream()
184+
.map(entry -> {
185+
double avg = entry.getValue().stream()
186+
.map(TemperatureReading::getTemperature)
187+
.filter(java.util.Objects::nonNull)
188+
.mapToDouble(Double::doubleValue)
189+
.average()
190+
.orElse(0.0d);
191+
192+
boolean hasDeviation = entry.getValue().stream().anyMatch(TemperatureReading::isDeviation);
193+
return new TemperatureReadingStatsPointResponse(entry.getKey(), avg, hasDeviation);
194+
})
195+
.toList();
196+
197+
return new TemperatureReadingStatsSeriesResponse(
198+
firstReading.getUnit().getId(),
199+
firstReading.getUnit().getName(),
200+
dataPoints
201+
);
202+
}
203+
204+
private TemperatureReadingDeviationResponse toDeviationResponse(TemperatureReading reading) {
205+
return new TemperatureReadingDeviationResponse(
206+
reading.getId(),
207+
reading.getUnit().getId(),
208+
reading.getUnit().getName(),
209+
reading.getTemperature(),
210+
resolveBreachedThreshold(reading),
211+
reading.getRecordedAt()
212+
);
213+
}
214+
215+
private Double resolveBreachedThreshold(TemperatureReading reading) {
216+
if (reading.getUnit() == null || reading.getTemperature() == null) {
217+
return null;
218+
}
219+
220+
Double minThreshold = reading.getUnit().getMinThreshold();
221+
Double maxThreshold = reading.getUnit().getMaxThreshold();
222+
223+
if (minThreshold != null && reading.getTemperature() < minThreshold) {
224+
return minThreshold;
225+
}
226+
227+
if (maxThreshold != null && reading.getTemperature() > maxThreshold) {
228+
return maxThreshold;
229+
}
230+
231+
return null;
232+
}
233+
234+
private LocalDateTime truncateTimestamp(LocalDateTime timestamp, TemperatureReadingStatsGroupBy groupBy) {
235+
return switch (groupBy) {
236+
case HOUR -> timestamp.withMinute(0).withSecond(0).withNano(0);
237+
case DAY -> timestamp.toLocalDate().atStartOfDay();
238+
case WEEK -> timestamp.toLocalDate()
239+
.with(TemporalAdjusters.previousOrSame(java.time.DayOfWeek.MONDAY))
240+
.atStartOfDay();
241+
};
242+
}
118243
}

backend/src/main/java/backend/fullstack/temperature/infrastructure/TemperatureReadingRepository.java

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,36 @@ List<TemperatureReading> findByOrganizationAndUnitAndRecordedAtBetween(
5555
@Param("to") LocalDateTime to
5656
);
5757

58+
@Query("""
59+
SELECT r
60+
FROM TemperatureReading r
61+
WHERE r.organization.id = :organizationId
62+
AND (:from IS NULL OR r.recordedAt >= :from)
63+
AND (:to IS NULL OR r.recordedAt <= :to)
64+
ORDER BY r.unit.name ASC, r.recordedAt ASC
65+
""")
66+
List<TemperatureReading> findForStatsByOrganizationAndRange(
67+
@Param("organizationId") Long organizationId,
68+
@Param("from") LocalDateTime from,
69+
@Param("to") LocalDateTime to
70+
);
71+
72+
@Query("""
73+
SELECT r
74+
FROM TemperatureReading r
75+
WHERE r.organization.id = :organizationId
76+
AND r.unit.id IN :unitIds
77+
AND (:from IS NULL OR r.recordedAt >= :from)
78+
AND (:to IS NULL OR r.recordedAt <= :to)
79+
ORDER BY r.unit.name ASC, r.recordedAt ASC
80+
""")
81+
List<TemperatureReading> findForStatsByOrganizationAndUnitIdsAndRange(
82+
@Param("organizationId") Long organizationId,
83+
@Param("unitIds") List<Long> unitIds,
84+
@Param("from") LocalDateTime from,
85+
@Param("to") LocalDateTime to
86+
);
87+
5888
long countByOrganization_IdAndIsDeviationTrue(Long organizationId);
5989

6090
long countByOrganization_IdAndIsDeviationTrueAndRecordedAtAfter(Long organizationId, LocalDateTime since);

backend/src/test/java/backend/fullstack/temperature/api/TemperatureReadingControllerSecurityAnnotationsTest.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ void globalReadingsRequireAdminOrManager() {
2020
assertPreAuthorize("getReadings", "hasAnyRole('ADMIN','MANAGER')");
2121
}
2222

23+
@Test
24+
void readingStatsRequireAdminOrManager() {
25+
assertPreAuthorize("getReadingStats", "hasAnyRole('ADMIN','MANAGER')");
26+
}
27+
2328
@Test
2429
void createReadingAllowsAllOperationalRoles() {
2530
assertPreAuthorize("createReading", "hasAnyRole('ADMIN','MANAGER','STAFF','SUPERVISOR')");

0 commit comments

Comments
 (0)