From ec5dede34915568fbe6e7203144fe313bf5673cd Mon Sep 17 00:00:00 2001 From: Sindre Boe Date: Tue, 7 Apr 2026 15:18:21 +0200 Subject: [PATCH 01/11] Add TemperatureReading entity with fields for temperature data and relationships --- .../domain/TemperatureReading.java | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 backend/src/main/java/backend/fullstack/temperature/domain/TemperatureReading.java diff --git a/backend/src/main/java/backend/fullstack/temperature/domain/TemperatureReading.java b/backend/src/main/java/backend/fullstack/temperature/domain/TemperatureReading.java new file mode 100644 index 0000000..78ada90 --- /dev/null +++ b/backend/src/main/java/backend/fullstack/temperature/domain/TemperatureReading.java @@ -0,0 +1,106 @@ +package backend.fullstack.temperature.domain; + +import java.time.LocalDateTime; + +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import backend.fullstack.organization.Organization; +import backend.fullstack.units.domain.TemperatureUnit; +import backend.fullstack.user.User; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Table( + name = "temperature_readings", + indexes = { + @Index(name = "idx_temperature_readings_organization_id", columnList = "organization_id"), + @Index(name = "idx_temperature_readings_unit_id", columnList = "unit_id"), + @Index(name = "idx_temperature_readings_recorded_at", columnList = "recorded_at"), + @Index(name = "idx_temperature_readings_is_deviation", columnList = "is_deviation") + } +) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class TemperatureReading { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "organization_id", nullable = false) + private Organization organization; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "unit_id", nullable = false) + private TemperatureUnit unit; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "recorded_by_user_id", nullable = false) + private User recordedBy; + + @NotNull(message = "Temperature is required") + @Column(name = "temperature", nullable = false) + private Double temperature; + + @NotNull(message = "Recorded time is required") + @Column(name = "recorded_at", nullable = false) + private LocalDateTime recordedAt; + + @Size(max = 500, message = "Note must be at most 500 characters") + @Column(name = "note", length = 500) + private String note; + + @Column(name = "is_deviation", nullable = false) + @Builder.Default + private boolean isDeviation = false; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @UpdateTimestamp + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + public Long getOrganizationId() { + return organization != null ? organization.getId() : null; + } + + public Long getUnitId() { + return unit != null ? unit.getId() : null; + } + + public Long getRecordedByUserId() { + return recordedBy != null ? recordedBy.getId() : null; + } + + public void evaluateDeviation(Double minThreshold, Double maxThreshold) { + if (temperature == null || minThreshold == null || maxThreshold == null) { + this.isDeviation = false; + return; + } + + this.isDeviation = temperature < minThreshold || temperature > maxThreshold; + } +} \ No newline at end of file From fc3cd653b6691b49e2c0f19085a76cbe754777d8 Mon Sep 17 00:00:00 2001 From: Sindre Boe Date: Tue, 7 Apr 2026 15:32:54 +0200 Subject: [PATCH 02/11] Add TemperatureReadingRepository with query methods for temperature readings --- .../TemperatureReadingRepository.java | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 backend/src/main/java/backend/fullstack/temperature/infrastructure/TemperatureReadingRepository.java diff --git a/backend/src/main/java/backend/fullstack/temperature/infrastructure/TemperatureReadingRepository.java b/backend/src/main/java/backend/fullstack/temperature/infrastructure/TemperatureReadingRepository.java new file mode 100644 index 0000000..7a11fe6 --- /dev/null +++ b/backend/src/main/java/backend/fullstack/temperature/infrastructure/TemperatureReadingRepository.java @@ -0,0 +1,59 @@ +package backend.fullstack.temperature.infrastructure; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import backend.fullstack.temperature.domain.TemperatureReading; + +@Repository +public interface TemperatureReadingRepository extends JpaRepository { + + List findByOrganization_IdAndUnit_IdOrderByRecordedAtDesc(Long organizationId, Long unitId); + + Optional findByIdAndOrganization_Id(Long id, Long organizationId); + + @Query(""" + SELECT r + FROM TemperatureReading r + WHERE r.organization.id = :organizationId + AND (:unitId IS NULL OR r.unit.id = :unitId) + AND (:from IS NULL OR r.recordedAt >= :from) + AND (:to IS NULL OR r.recordedAt <= :to) + AND (:deviationsOnly = false OR r.isDeviation = true) + ORDER BY r.recordedAt DESC + """) + Page findAllByOrganizationWithFilters( + @Param("organizationId") Long organizationId, + @Param("unitId") Long unitId, + @Param("from") LocalDateTime from, + @Param("to") LocalDateTime to, + @Param("deviationsOnly") boolean deviationsOnly, + Pageable pageable + ); + + @Query(""" + SELECT r + FROM TemperatureReading r + WHERE r.organization.id = :organizationId + AND r.unit.id = :unitId + AND (:from IS NULL OR r.recordedAt >= :from) + AND (:to IS NULL OR r.recordedAt <= :to) + ORDER BY r.recordedAt DESC + """) + List findByOrganizationAndUnitAndRecordedAtBetween( + @Param("organizationId") Long organizationId, + @Param("unitId") Long unitId, + @Param("from") LocalDateTime from, + @Param("to") LocalDateTime to + ); + + long countByOrganization_IdAndIsDeviationTrue(Long organizationId); +} \ No newline at end of file From e1ba524b306ca2d158345e217f405a5ad4c774f8 Mon Sep 17 00:00:00 2001 From: Sindre Boe Date: Tue, 7 Apr 2026 15:34:18 +0200 Subject: [PATCH 03/11] Add DTOs for temperature readings: RecordedByResponse, TemperatureReadingRequest, and TemperatureReadingResponse --- .../api/dto/RecordedByResponse.java | 7 +++++++ .../api/dto/TemperatureReadingRequest.java | 19 +++++++++++++++++ .../api/dto/TemperatureReadingResponse.java | 21 +++++++++++++++++++ 3 files changed, 47 insertions(+) create mode 100644 backend/src/main/java/backend/fullstack/temperature/api/dto/RecordedByResponse.java create mode 100644 backend/src/main/java/backend/fullstack/temperature/api/dto/TemperatureReadingRequest.java create mode 100644 backend/src/main/java/backend/fullstack/temperature/api/dto/TemperatureReadingResponse.java diff --git a/backend/src/main/java/backend/fullstack/temperature/api/dto/RecordedByResponse.java b/backend/src/main/java/backend/fullstack/temperature/api/dto/RecordedByResponse.java new file mode 100644 index 0000000..d8abd63 --- /dev/null +++ b/backend/src/main/java/backend/fullstack/temperature/api/dto/RecordedByResponse.java @@ -0,0 +1,7 @@ +package backend.fullstack.temperature.api.dto; + +public record RecordedByResponse( + Long id, + String name +) { +} \ No newline at end of file diff --git a/backend/src/main/java/backend/fullstack/temperature/api/dto/TemperatureReadingRequest.java b/backend/src/main/java/backend/fullstack/temperature/api/dto/TemperatureReadingRequest.java new file mode 100644 index 0000000..3ae7c51 --- /dev/null +++ b/backend/src/main/java/backend/fullstack/temperature/api/dto/TemperatureReadingRequest.java @@ -0,0 +1,19 @@ +package backend.fullstack.temperature.api.dto; + +import java.time.LocalDateTime; + +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record TemperatureReadingRequest( + @NotNull(message = "Temperature is required") + @DecimalMin(value = "-273.15", message = "Temperature must be greater than or equal to -273.15") + Double temperature, + + LocalDateTime recordedAt, + + @Size(max = 500, message = "Note must be at most 500 characters") + String note +) { +} \ No newline at end of file diff --git a/backend/src/main/java/backend/fullstack/temperature/api/dto/TemperatureReadingResponse.java b/backend/src/main/java/backend/fullstack/temperature/api/dto/TemperatureReadingResponse.java new file mode 100644 index 0000000..c3324b8 --- /dev/null +++ b/backend/src/main/java/backend/fullstack/temperature/api/dto/TemperatureReadingResponse.java @@ -0,0 +1,21 @@ +package backend.fullstack.temperature.api.dto; + +import java.time.LocalDateTime; + +public record TemperatureReadingResponse( + Long id, + Long organizationId, + Long unitId, + String unitName, + Double temperature, + Double targetTemperature, + Double minThreshold, + Double maxThreshold, + boolean isDeviation, + LocalDateTime recordedAt, + String note, + RecordedByResponse recordedBy, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { +} \ No newline at end of file From bdc9e846c86cada535b4db427dcb7345c4624b2c Mon Sep 17 00:00:00 2001 From: Sindre Boe Date: Tue, 7 Apr 2026 15:53:50 +0200 Subject: [PATCH 04/11] Add TemperatureReadingMapper for mapping between TemperatureReadingRequest and TemperatureReadingResponse --- .../api/dto/TemperatureReadingMapper.java | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 backend/src/main/java/backend/fullstack/temperature/api/dto/TemperatureReadingMapper.java diff --git a/backend/src/main/java/backend/fullstack/temperature/api/dto/TemperatureReadingMapper.java b/backend/src/main/java/backend/fullstack/temperature/api/dto/TemperatureReadingMapper.java new file mode 100644 index 0000000..2c01547 --- /dev/null +++ b/backend/src/main/java/backend/fullstack/temperature/api/dto/TemperatureReadingMapper.java @@ -0,0 +1,45 @@ +package backend.fullstack.temperature.api.dto; + +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.Named; + +import backend.fullstack.temperature.domain.TemperatureReading; +import backend.fullstack.user.User; + +@Mapper(componentModel = "spring") +public interface TemperatureReadingMapper { + + @Mapping(target = "id", ignore = true) + @Mapping(target = "organization", ignore = true) + @Mapping(target = "unit", ignore = true) + @Mapping(target = "recordedBy", ignore = true) + @Mapping(target = "isDeviation", ignore = true) + @Mapping(target = "createdAt", ignore = true) + @Mapping(target = "updatedAt", ignore = true) + @Mapping(target = "recordedAt", expression = "java(request.recordedAt() != null ? request.recordedAt() : java.time.LocalDateTime.now())") + TemperatureReading toEntity(TemperatureReadingRequest request); + + @Mapping(target = "organizationId", source = "organization.id") + @Mapping(target = "unitId", source = "unit.id") + @Mapping(target = "unitName", source = "unit.name") + @Mapping(target = "targetTemperature", source = "unit.targetTemperature") + @Mapping(target = "minThreshold", source = "unit.minThreshold") + @Mapping(target = "maxThreshold", source = "unit.maxThreshold") + @Mapping(target = "recordedBy", source = "recordedBy", qualifiedByName = "toRecordedByResponse") + @Mapping(target = "isDeviation", source = "deviation") + TemperatureReadingResponse toResponse(TemperatureReading reading); + + default RecordedByResponse toRecordedByResponse(User user) { + if (user == null) { + return null; + } + + return new RecordedByResponse(user.getId(), user.getFirstName() + " " + user.getLastName()); + } + + @Named("toRecordedByResponse") + default RecordedByResponse mapRecordedBy(User user) { + return toRecordedByResponse(user); + } +} \ No newline at end of file From fc362bf80c96e9f1f6a9972afe60a7ad16a608cf Mon Sep 17 00:00:00 2001 From: Sindre Boe Date: Tue, 7 Apr 2026 16:04:12 +0200 Subject: [PATCH 05/11] Add TemperatureReadingService for managing temperature readings and related operations --- .../TemperatureReadingService.java | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 backend/src/main/java/backend/fullstack/temperature/application/TemperatureReadingService.java diff --git a/backend/src/main/java/backend/fullstack/temperature/application/TemperatureReadingService.java b/backend/src/main/java/backend/fullstack/temperature/application/TemperatureReadingService.java new file mode 100644 index 0000000..d81ba96 --- /dev/null +++ b/backend/src/main/java/backend/fullstack/temperature/application/TemperatureReadingService.java @@ -0,0 +1,119 @@ +package backend.fullstack.temperature.application; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import backend.fullstack.config.JwtPrincipal; +import backend.fullstack.exceptions.ResourceNotFoundException; +import backend.fullstack.exceptions.UnitInactiveException; +import backend.fullstack.exceptions.UnitNotFoundException; +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.domain.TemperatureReading; +import backend.fullstack.temperature.infrastructure.TemperatureReadingRepository; +import backend.fullstack.units.domain.TemperatureUnit; +import backend.fullstack.units.infrastructure.TemperatureUnitRepository; +import backend.fullstack.user.User; +import backend.fullstack.user.UserRepository; + +@Service +@Transactional +public class TemperatureReadingService { + + private final TemperatureReadingRepository readingRepository; + private final TemperatureUnitRepository unitRepository; + private final UserRepository userRepository; + private final TemperatureReadingMapper readingMapper; + + public TemperatureReadingService( + TemperatureReadingRepository readingRepository, + TemperatureUnitRepository unitRepository, + UserRepository userRepository, + TemperatureReadingMapper readingMapper + ) { + this.readingRepository = readingRepository; + this.unitRepository = unitRepository; + this.userRepository = userRepository; + this.readingMapper = readingMapper; + } + + @Transactional(readOnly = true) + public List listUnitReadings( + Long organizationId, + Long unitId, + LocalDateTime from, + LocalDateTime to + ) { + return readingRepository.findByOrganizationAndUnitAndRecordedAtBetween(organizationId, unitId, from, to) + .stream() + .map(readingMapper::toResponse) + .toList(); + } + + @Transactional(readOnly = true) + public Page listReadings( + Long organizationId, + Long unitId, + LocalDateTime from, + LocalDateTime to, + boolean deviationsOnly, + Pageable pageable + ) { + return readingRepository.findAllByOrganizationWithFilters(organizationId, unitId, from, to, deviationsOnly, pageable) + .map(readingMapper::toResponse); + } + + public TemperatureReadingResponse createReading( + JwtPrincipal principal, + Long unitId, + TemperatureReadingRequest request + ) { + TemperatureUnit unit = findScopedActiveUnit(principal.organizationId(), unitId); + User recordedBy = findScopedUser(principal.userId(), principal.organizationId()); + + TemperatureReading reading = readingMapper.toEntity(request); + reading.setOrganization(unit.getOrganization()); + reading.setUnit(unit); + reading.setRecordedBy(recordedBy); + reading.setRecordedAt(reading.getRecordedAt() != null ? reading.getRecordedAt() : LocalDateTime.now()); + reading.evaluateDeviation(unit.getMinThreshold(), unit.getMaxThreshold()); + + TemperatureReading saved = readingRepository.save(reading); + return readingMapper.toResponse(saved); + } + + @Transactional(readOnly = true) + public TemperatureReadingResponse getReading(Long organizationId, Long readingId) { + TemperatureReading reading = readingRepository.findByIdAndOrganization_Id(readingId, organizationId) + .orElseThrow(() -> new ResourceNotFoundException("Temperature reading", readingId)); + return readingMapper.toResponse(reading); + } + + private TemperatureUnit findScopedActiveUnit(Long organizationId, Long unitId) { + TemperatureUnit unit = unitRepository.findByIdAndOrganization_Id(unitId, organizationId) + .orElseThrow(() -> new UnitNotFoundException(unitId)); + + if (!unit.isActive()) { + throw new UnitInactiveException(unit.getId()); + } + + return unit; + } + + private User findScopedUser(Long userId, Long organizationId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new ResourceNotFoundException("User", userId)); + + if (!organizationId.equals(user.getOrganizationId())) { + throw new ResourceNotFoundException("User", userId); + } + + return user; + } +} \ No newline at end of file From 60a9ac7ace86acff669767cba14c5cf8366826c8 Mon Sep 17 00:00:00 2001 From: Sindre Boe Date: Tue, 7 Apr 2026 16:21:16 +0200 Subject: [PATCH 06/11] Add TemperatureReadingController for managing temperature readings endpoints --- .../api/TemperatureReadingController.java | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 backend/src/main/java/backend/fullstack/temperature/api/TemperatureReadingController.java diff --git a/backend/src/main/java/backend/fullstack/temperature/api/TemperatureReadingController.java b/backend/src/main/java/backend/fullstack/temperature/api/TemperatureReadingController.java new file mode 100644 index 0000000..3cdd0c6 --- /dev/null +++ b/backend/src/main/java/backend/fullstack/temperature/api/TemperatureReadingController.java @@ -0,0 +1,113 @@ +package backend.fullstack.temperature.api; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import backend.fullstack.config.ApiResponse; +import backend.fullstack.config.JwtPrincipal; +import backend.fullstack.temperature.api.dto.TemperatureReadingRequest; +import backend.fullstack.temperature.api.dto.TemperatureReadingResponse; +import backend.fullstack.temperature.application.TemperatureReadingService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; + +@RestController +@RequestMapping("/api") +@Tag(name = "Temperature Readings", description = "Temperature logging endpoints") +@SecurityRequirement(name = "Bearer Auth") +public class TemperatureReadingController { + + private final TemperatureReadingService readingService; + + public TemperatureReadingController(TemperatureReadingService readingService) { + this.readingService = readingService; + } + + @GetMapping("/units/{unitId}/readings") + @PreAuthorize("hasAnyRole('ADMIN','MANAGER','STAFF','SUPERVISOR')") + @Operation(summary = "List readings for a unit") + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Readings 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>> getUnitReadings( + @AuthenticationPrincipal JwtPrincipal principal, + @PathVariable Long unitId, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime from, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime to + ) { + List readings = + readingService.listUnitReadings(principal.organizationId(), unitId, from, to); + return ResponseEntity.ok(ApiResponse.success("Readings retrieved", readings)); + } + + @GetMapping("/readings") + @PreAuthorize("hasAnyRole('ADMIN','MANAGER')") + @Operation(summary = "List readings with filters") + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Readings 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>> getReadings( + @AuthenticationPrincipal JwtPrincipal principal, + @RequestParam(required = false) Long unitId, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime from, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime to, + @RequestParam(defaultValue = "false") boolean deviationsOnly, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "recordedAt")); + Page readings = readingService.listReadings( + principal.organizationId(), + unitId, + from, + to, + deviationsOnly, + pageable + ); + + return ResponseEntity.ok(ApiResponse.success("Readings retrieved", readings)); + } + + @PostMapping("/units/{unitId}/readings") + @PreAuthorize("hasAnyRole('ADMIN','MANAGER','STAFF','SUPERVISOR')") + @Operation(summary = "Register temperature reading") + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "201", description = "Reading created"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Validation error"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "Unauthorized"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Forbidden"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Unit not found"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Unit inactive") + }) + public ResponseEntity> createReading( + @AuthenticationPrincipal JwtPrincipal principal, + @PathVariable Long unitId, + @Valid @RequestBody TemperatureReadingRequest request + ) { + TemperatureReadingResponse response = readingService.createReading(principal, unitId, request); + return ResponseEntity.status(201).body(ApiResponse.success("Reading created", response)); + } +} \ No newline at end of file From b3971b07a4715ab13af881f7030f344fdfed8919 Mon Sep 17 00:00:00 2001 From: Sindre Boe Date: Tue, 7 Apr 2026 16:56:57 +0200 Subject: [PATCH 07/11] Add SQL migration for creating temperature_readings table with indexes --- .../V3__create_temperature_readings_table.sql | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 backend/src/main/resources/db/migration/V3__create_temperature_readings_table.sql diff --git a/backend/src/main/resources/db/migration/V3__create_temperature_readings_table.sql b/backend/src/main/resources/db/migration/V3__create_temperature_readings_table.sql new file mode 100644 index 0000000..27071b9 --- /dev/null +++ b/backend/src/main/resources/db/migration/V3__create_temperature_readings_table.sql @@ -0,0 +1,24 @@ +CREATE TABLE temperature_readings ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + organization_id BIGINT NOT NULL, + unit_id BIGINT NOT NULL, + recorded_by_user_id BIGINT NOT NULL, + temperature DOUBLE NOT NULL, + recorded_at TIMESTAMP NOT NULL, + note VARCHAR(500), + is_deviation BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_temperature_readings_organization_id + ON temperature_readings(organization_id); + +CREATE INDEX idx_temperature_readings_unit_id + ON temperature_readings(unit_id); + +CREATE INDEX idx_temperature_readings_recorded_at + ON temperature_readings(recorded_at); + +CREATE INDEX idx_temperature_readings_is_deviation + ON temperature_readings(is_deviation); \ No newline at end of file From 6023538f61d1ebeeb7a0c4b4b725c8520548bfee Mon Sep 17 00:00:00 2001 From: Sindre Boe Date: Tue, 7 Apr 2026 17:00:54 +0200 Subject: [PATCH 08/11] Add unit tests for TemperatureReadingController and TemperatureReadingService --- ...dingControllerSecurityAnnotationsTest.java | 38 +++ ...mperatureReadingRequestValidationTest.java | 42 +++ .../TemperatureReadingServiceTest.java | 256 ++++++++++++++++++ 3 files changed, 336 insertions(+) create mode 100644 backend/src/test/java/backend/fullstack/temperature/api/TemperatureReadingControllerSecurityAnnotationsTest.java create mode 100644 backend/src/test/java/backend/fullstack/temperature/api/dto/TemperatureReadingRequestValidationTest.java create mode 100644 backend/src/test/java/backend/fullstack/temperature/application/TemperatureReadingServiceTest.java diff --git a/backend/src/test/java/backend/fullstack/temperature/api/TemperatureReadingControllerSecurityAnnotationsTest.java b/backend/src/test/java/backend/fullstack/temperature/api/TemperatureReadingControllerSecurityAnnotationsTest.java new file mode 100644 index 0000000..eb1bc78 --- /dev/null +++ b/backend/src/test/java/backend/fullstack/temperature/api/TemperatureReadingControllerSecurityAnnotationsTest.java @@ -0,0 +1,38 @@ +package backend.fullstack.temperature.api; + +import java.lang.reflect.Method; +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; + +class TemperatureReadingControllerSecurityAnnotationsTest { + + @Test + void unitReadingsAllowAllOperationalRoles() { + assertPreAuthorize("getUnitReadings", "hasAnyRole('ADMIN','MANAGER','STAFF','SUPERVISOR')"); + } + + @Test + void globalReadingsRequireAdminOrManager() { + assertPreAuthorize("getReadings", "hasAnyRole('ADMIN','MANAGER')"); + } + + @Test + void createReadingAllowsAllOperationalRoles() { + assertPreAuthorize("createReading", "hasAnyRole('ADMIN','MANAGER','STAFF','SUPERVISOR')"); + } + + private void assertPreAuthorize(String methodName, String expectedValue) { + Method method = Arrays.stream(TemperatureReadingController.class.getDeclaredMethods()) + .filter(candidate -> methodName.equals(candidate.getName())) + .findFirst() + .orElseThrow(() -> new IllegalStateException("Method not found: " + methodName)); + + PreAuthorize annotation = method.getAnnotation(PreAuthorize.class); + assertNotNull(annotation, "@PreAuthorize must be present on " + methodName); + assertEquals(expectedValue, annotation.value(), "Unexpected role guard on " + methodName); + } +} \ No newline at end of file diff --git a/backend/src/test/java/backend/fullstack/temperature/api/dto/TemperatureReadingRequestValidationTest.java b/backend/src/test/java/backend/fullstack/temperature/api/dto/TemperatureReadingRequestValidationTest.java new file mode 100644 index 0000000..1a2ec00 --- /dev/null +++ b/backend/src/test/java/backend/fullstack/temperature/api/dto/TemperatureReadingRequestValidationTest.java @@ -0,0 +1,42 @@ +package backend.fullstack.temperature.api.dto; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import org.junit.jupiter.api.Test; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import jakarta.validation.ValidatorFactory; + +class TemperatureReadingRequestValidationTest { + + private final Validator validator; + + TemperatureReadingRequestValidationTest() { + ValidatorFactory validatorFactory = Validation.buildDefaultValidatorFactory(); + this.validator = validatorFactory.getValidator(); + } + + @Test + void validRequestPassesValidation() { + TemperatureReadingRequest request = new TemperatureReadingRequest(2.5, null, "OK"); + + Set> violations = validator.validate(request); + + assertTrue(violations.isEmpty()); + } + + @Test + void missingTemperatureFailsValidation() { + TemperatureReadingRequest request = new TemperatureReadingRequest(null, null, "OK"); + + Set> violations = validator.validate(request); + + assertEquals(1, violations.size()); + ConstraintViolation violation = violations.iterator().next(); + assertEquals("Temperature is required", violation.getMessage()); + } +} \ No newline at end of file diff --git a/backend/src/test/java/backend/fullstack/temperature/application/TemperatureReadingServiceTest.java b/backend/src/test/java/backend/fullstack/temperature/application/TemperatureReadingServiceTest.java new file mode 100644 index 0000000..6c86b96 --- /dev/null +++ b/backend/src/test/java/backend/fullstack/temperature/application/TemperatureReadingServiceTest.java @@ -0,0 +1,256 @@ +package backend.fullstack.temperature.application; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import static org.mockito.ArgumentMatchers.any; +import org.mockito.Mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; + +import backend.fullstack.config.JwtPrincipal; +import backend.fullstack.exceptions.ResourceNotFoundException; +import backend.fullstack.exceptions.UnitInactiveException; +import backend.fullstack.exceptions.UnitNotFoundException; +import backend.fullstack.organization.Organization; +import backend.fullstack.temperature.api.dto.RecordedByResponse; +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.domain.TemperatureReading; +import backend.fullstack.temperature.infrastructure.TemperatureReadingRepository; +import backend.fullstack.units.domain.TemperatureUnit; +import backend.fullstack.units.domain.UnitType; +import backend.fullstack.units.infrastructure.TemperatureUnitRepository; +import backend.fullstack.user.User; +import backend.fullstack.user.UserRepository; +import backend.fullstack.user.role.Role; + +@ExtendWith(MockitoExtension.class) +class TemperatureReadingServiceTest { + + @Mock + private TemperatureReadingRepository readingRepository; + + @Mock + private TemperatureUnitRepository unitRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private TemperatureReadingMapper readingMapper; + + private TemperatureReadingService service; + + @BeforeEach + void setUp() { + service = new TemperatureReadingService(readingRepository, unitRepository, userRepository, readingMapper); + } + + @Test + void createReadingEvaluatesDeviationAndPersistsReading() { + JwtPrincipal principal = new JwtPrincipal(10L, "staff@everest.no", Role.STAFF, 1L, List.of()); + TemperatureUnit unit = unit(5L, 1L, true, 1.0, 5.0); + User user = user(10L, 1L); + TemperatureReadingRequest request = new TemperatureReadingRequest(6.5, null, "Door open"); + TemperatureReading mapped = TemperatureReading.builder() + .temperature(6.5) + .recordedAt(LocalDateTime.of(2026, 3, 20, 8, 10)) + .note("Door open") + .build(); + TemperatureReading saved = TemperatureReading.builder() + .id(99L) + .organization(unit.getOrganization()) + .unit(unit) + .recordedBy(user) + .temperature(6.5) + .recordedAt(mapped.getRecordedAt()) + .note("Door open") + .isDeviation(true) + .build(); + TemperatureReadingResponse expected = response(99L, true); + + when(unitRepository.findByIdAndOrganization_Id(5L, 1L)).thenReturn(Optional.of(unit)); + when(userRepository.findById(10L)).thenReturn(Optional.of(user)); + when(readingMapper.toEntity(request)).thenReturn(mapped); + when(readingRepository.save(any(TemperatureReading.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(readingMapper.toResponse(any(TemperatureReading.class))).thenReturn(expected); + + TemperatureReadingResponse actual = service.createReading(principal, 5L, request); + + ArgumentCaptor captor = ArgumentCaptor.forClass(TemperatureReading.class); + verify(readingRepository).save(captor.capture()); + + TemperatureReading persisted = captor.getValue(); + assertEquals(1L, persisted.getOrganizationId()); + assertEquals(5L, persisted.getUnitId()); + assertEquals(10L, persisted.getRecordedByUserId()); + assertTrue(persisted.isDeviation()); + assertNotNull(persisted.getRecordedAt()); + assertEquals(expected, actual); + } + + @Test + void createReadingThrowsWhenUnitIsInactive() { + JwtPrincipal principal = new JwtPrincipal(10L, "staff@everest.no", Role.STAFF, 1L, List.of()); + TemperatureUnit unit = unit(5L, 1L, false, 1.0, 5.0); + + when(unitRepository.findByIdAndOrganization_Id(5L, 1L)).thenReturn(Optional.of(unit)); + + assertThrows(UnitInactiveException.class, () -> service.createReading( + principal, + 5L, + new TemperatureReadingRequest(2.0, null, null) + )); + + verify(userRepository, never()).findById(any()); + verify(readingRepository, never()).save(any()); + } + + @Test + void createReadingThrowsWhenUnitIsMissing() { + JwtPrincipal principal = new JwtPrincipal(10L, "staff@everest.no", Role.STAFF, 1L, List.of()); + + when(unitRepository.findByIdAndOrganization_Id(5L, 1L)).thenReturn(Optional.empty()); + + assertThrows(UnitNotFoundException.class, () -> service.createReading( + principal, + 5L, + new TemperatureReadingRequest(2.0, null, null) + )); + + verify(userRepository, never()).findById(any()); + verify(readingRepository, never()).save(any()); + } + + @Test + void listReadingsReturnsPagedMappedResults() { + TemperatureReading reading = reading(1L, true); + TemperatureReadingResponse response = response(1L, true); + + when(readingRepository.findAllByOrganizationWithFilters(1L, 5L, null, null, true, PageRequest.of(0, 20))) + .thenReturn(new PageImpl<>(List.of(reading))); + when(readingMapper.toResponse(reading)).thenReturn(response); + + var page = service.listReadings(1L, 5L, null, null, true, PageRequest.of(0, 20)); + + assertEquals(1, page.getTotalElements()); + assertEquals(response, page.getContent().get(0)); + } + + @Test + void getReadingThrowsWhenReadingMissingForOrganization() { + when(readingRepository.findByIdAndOrganization_Id(11L, 1L)).thenReturn(Optional.empty()); + + assertThrows(ResourceNotFoundException.class, () -> service.getReading(1L, 11L)); + } + + @Test + void listUnitReadingsMapsResults() { + TemperatureReading reading = reading(1L, false); + TemperatureReadingResponse response = response(1L, false); + + when(readingRepository.findByOrganizationAndUnitAndRecordedAtBetween(1L, 5L, null, null)) + .thenReturn(List.of(reading)); + when(readingMapper.toResponse(reading)).thenReturn(response); + + List results = service.listUnitReadings(1L, 5L, null, null); + + assertEquals(1, results.size()); + assertEquals(response, results.get(0)); + assertFalse(results.get(0).isDeviation()); + } + + private static TemperatureUnit unit(Long id, Long organizationId, boolean active, double min, double max) { + Organization organization = Organization.builder() + .id(organizationId) + .name("Everest") + .organizationNumber("123456789") + .build(); + + return TemperatureUnit.builder() + .id(id) + .organization(organization) + .name("Freezer A") + .type(UnitType.FREEZER) + .targetTemperature(3.0) + .minThreshold(min) + .maxThreshold(max) + .description("Main storage") + .active(active) + .build(); + } + + private static User user(Long id, Long organizationId) { + Organization organization = Organization.builder() + .id(organizationId) + .name("Everest") + .organizationNumber("123456789") + .build(); + + return User.builder() + .id(id) + .organization(organization) + .email("staff@everest.no") + .firstName("Kari") + .lastName("Larsen") + .passwordHash("hash") + .role(Role.STAFF) + .build(); + } + + private static TemperatureReading reading(Long id, boolean deviation) { + Organization organization = Organization.builder() + .id(1L) + .name("Everest") + .organizationNumber("123456789") + .build(); + TemperatureUnit unit = unit(5L, 1L, true, 1.0, 5.0); + User user = user(10L, 1L); + + return TemperatureReading.builder() + .id(id) + .organization(organization) + .unit(unit) + .recordedBy(user) + .temperature(2.5) + .recordedAt(LocalDateTime.of(2026, 3, 20, 8, 10)) + .note("OK") + .isDeviation(deviation) + .build(); + } + + private static TemperatureReadingResponse response(Long id, boolean deviation) { + return new TemperatureReadingResponse( + id, + 1L, + 5L, + "Freezer A", + 2.5, + 3.0, + 1.0, + 5.0, + deviation, + LocalDateTime.of(2026, 3, 20, 8, 10), + "OK", + new RecordedByResponse(10L, "Kari Larsen"), + null, + null + ); + } +} \ No newline at end of file From 8e7f31dc194ac3d5f8a6f70f5edd6b45c6c92c86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sindre=20Jentoft=20B=C3=B8e?= Date: Tue, 7 Apr 2026 19:51:12 +0200 Subject: [PATCH 09/11] mapper fix to service Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../temperature/application/TemperatureReadingService.java | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/src/main/java/backend/fullstack/temperature/application/TemperatureReadingService.java b/backend/src/main/java/backend/fullstack/temperature/application/TemperatureReadingService.java index d81ba96..ce7ecc0 100644 --- a/backend/src/main/java/backend/fullstack/temperature/application/TemperatureReadingService.java +++ b/backend/src/main/java/backend/fullstack/temperature/application/TemperatureReadingService.java @@ -81,7 +81,6 @@ public TemperatureReadingResponse createReading( reading.setOrganization(unit.getOrganization()); reading.setUnit(unit); reading.setRecordedBy(recordedBy); - reading.setRecordedAt(reading.getRecordedAt() != null ? reading.getRecordedAt() : LocalDateTime.now()); reading.evaluateDeviation(unit.getMinThreshold(), unit.getMaxThreshold()); TemperatureReading saved = readingRepository.save(reading); From a8d2d006fbfc59709c2cdd520a733dbdf22b123b Mon Sep 17 00:00:00 2001 From: Sindre Boe Date: Tue, 7 Apr 2026 19:56:29 +0200 Subject: [PATCH 10/11] Refactor TemperatureReadingMapper to remove redundant mapping method --- .../temperature/api/dto/TemperatureReadingMapper.java | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/backend/src/main/java/backend/fullstack/temperature/api/dto/TemperatureReadingMapper.java b/backend/src/main/java/backend/fullstack/temperature/api/dto/TemperatureReadingMapper.java index 2c01547..9747089 100644 --- a/backend/src/main/java/backend/fullstack/temperature/api/dto/TemperatureReadingMapper.java +++ b/backend/src/main/java/backend/fullstack/temperature/api/dto/TemperatureReadingMapper.java @@ -30,6 +30,7 @@ public interface TemperatureReadingMapper { @Mapping(target = "isDeviation", source = "deviation") TemperatureReadingResponse toResponse(TemperatureReading reading); + @Named("toRecordedByResponse") default RecordedByResponse toRecordedByResponse(User user) { if (user == null) { return null; @@ -37,9 +38,4 @@ default RecordedByResponse toRecordedByResponse(User user) { return new RecordedByResponse(user.getId(), user.getFirstName() + " " + user.getLastName()); } - - @Named("toRecordedByResponse") - default RecordedByResponse mapRecordedBy(User user) { - return toRecordedByResponse(user); - } } \ No newline at end of file From 79559262c72fc26d67a4fb0f5045655f26756db4 Mon Sep 17 00:00:00 2001 From: Sindre Boe Date: Tue, 7 Apr 2026 19:57:57 +0200 Subject: [PATCH 11/11] Refactor validation tests to use static validator initialization and cleanup --- ...mperatureReadingRequestValidationTest.java | 19 +++++++++++++++---- .../TemperatureThresholdsValidatorTest.java | 19 +++++++++++++++---- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/backend/src/test/java/backend/fullstack/temperature/api/dto/TemperatureReadingRequestValidationTest.java b/backend/src/test/java/backend/fullstack/temperature/api/dto/TemperatureReadingRequestValidationTest.java index 1a2ec00..f83cfb9 100644 --- a/backend/src/test/java/backend/fullstack/temperature/api/dto/TemperatureReadingRequestValidationTest.java +++ b/backend/src/test/java/backend/fullstack/temperature/api/dto/TemperatureReadingRequestValidationTest.java @@ -2,8 +2,10 @@ import java.util.Set; +import org.junit.jupiter.api.AfterAll; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import jakarta.validation.ConstraintViolation; @@ -13,11 +15,20 @@ class TemperatureReadingRequestValidationTest { - private final Validator validator; + private static ValidatorFactory validatorFactory; + private static Validator validator; - TemperatureReadingRequestValidationTest() { - ValidatorFactory validatorFactory = Validation.buildDefaultValidatorFactory(); - this.validator = validatorFactory.getValidator(); + @BeforeAll + static void initValidator() { + validatorFactory = Validation.buildDefaultValidatorFactory(); + validator = validatorFactory.getValidator(); + } + + @AfterAll + static void closeValidatorFactory() { + if (validatorFactory != null) { + validatorFactory.close(); + } } @Test diff --git a/backend/src/test/java/backend/fullstack/units/api/validation/TemperatureThresholdsValidatorTest.java b/backend/src/test/java/backend/fullstack/units/api/validation/TemperatureThresholdsValidatorTest.java index 60f4198..5f3616d 100644 --- a/backend/src/test/java/backend/fullstack/units/api/validation/TemperatureThresholdsValidatorTest.java +++ b/backend/src/test/java/backend/fullstack/units/api/validation/TemperatureThresholdsValidatorTest.java @@ -2,8 +2,10 @@ import java.util.Set; +import org.junit.jupiter.api.AfterAll; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import backend.fullstack.units.api.dto.UnitRequest; @@ -15,11 +17,20 @@ class TemperatureThresholdsValidatorTest { - private final Validator validator; + private static ValidatorFactory validatorFactory; + private static Validator validator; - TemperatureThresholdsValidatorTest() { - ValidatorFactory validatorFactory = Validation.buildDefaultValidatorFactory(); - this.validator = validatorFactory.getValidator(); + @BeforeAll + static void initValidator() { + validatorFactory = Validation.buildDefaultValidatorFactory(); + validator = validatorFactory.getValidator(); + } + + @AfterAll + static void closeValidatorFactory() { + if (validatorFactory != null) { + validatorFactory.close(); + } } @Test