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
@@ -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<ApiResponse<List<TemperatureReadingResponse>>> 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<TemperatureReadingResponse> 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<ApiResponse<Page<TemperatureReadingResponse>>> 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<TemperatureReadingResponse> 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<ApiResponse<TemperatureReadingResponse>> 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));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package backend.fullstack.temperature.api.dto;

public record RecordedByResponse(
Long id,
String name
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
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);

@Named("toRecordedByResponse")
default RecordedByResponse toRecordedByResponse(User user) {
if (user == null) {
return null;
}

return new RecordedByResponse(user.getId(), user.getFirstName() + " " + user.getLastName());
}
}
Original file line number Diff line number Diff line change
@@ -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
) {
}
Original file line number Diff line number Diff line change
@@ -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
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
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<TemperatureReadingResponse> 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<TemperatureReadingResponse> 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.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;
}
}
Loading
Loading