feat(sprint-6): Phase 5 — Full grow calendar (sensors, photos, feeding, harvest traceability)
Deploy to Production / test (push) Has been cancelled
Deploy to Production / deploy (push) Has been cancelled

- V9 migration: grow_entries, grow_stage_logs, sensor_readings, grow_photos, feeding_logs
- 5 entities + GrowStage enum (7 stages) + SensorReadingType enum
- GrowCalendarService: CRUD + stage advancement + harvest-to-batch linking
- GrowCalendarController: 8 endpoints (/api/v1/grow/*)
- Frontend: /grow list + /grow/[id] detail (timeline, sensor charts, photo gallery, feeding log)
- Sensor chart (Recharts line: temp + humidity over time)
- Harvest completion links grow entry → batch (full traceability)
- React Query hooks for all grow operations
- Full i18n (de/en) with 7 grow stage labels
- Sidebar navigation updated with Anbau/Grow entry
This commit is contained in:
Patrick Plate
2026-06-12 22:51:45 +02:00
parent 05933a08ca
commit 076fd6f9b3
34 changed files with 1843 additions and 2 deletions
@@ -0,0 +1,135 @@
package de.cannamanage.api.controller;
import de.cannamanage.api.dto.grow.*;
import de.cannamanage.domain.entity.*;
import de.cannamanage.domain.enums.GrowStage;
import de.cannamanage.domain.enums.SensorReadingType;
import de.cannamanage.service.GrowCalendarService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/api/v1/grow")
@RequiredArgsConstructor
@Tag(name = "Grow Calendar", description = "Grow lifecycle management with sensors, photos, and feeding")
public class GrowCalendarController {
private final GrowCalendarService growCalendarService;
@GetMapping
@Operation(summary = "List all grow entries")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_STOCK)")
public ResponseEntity<List<GrowEntryResponse>> listGrowEntries() {
List<GrowEntryResponse> entries = growCalendarService.getGrowEntries().stream()
.map(this::toResponse)
.toList();
return ResponseEntity.ok(entries);
}
@PostMapping
@Operation(summary = "Create a new grow entry")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).RECORD_STOCK_IN)")
public ResponseEntity<GrowEntryResponse> createGrowEntry(@Valid @RequestBody CreateGrowEntryRequest request) {
GrowEntry entry = growCalendarService.createGrowEntry(
request.name(), request.strainId(), request.notes(), request.expectedHarvestAt());
return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(entry));
}
@GetMapping("/{id}")
@Operation(summary = "Get grow entry detail with stages, sensors, photos, feedings")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_STOCK)")
public ResponseEntity<GrowEntryDetailResponse> getGrowEntry(@PathVariable UUID id) {
GrowEntry entry = growCalendarService.getGrowEntry(id);
List<GrowStageLog> stages = growCalendarService.getStageLogs(id);
List<SensorReading> sensors = growCalendarService.getSensorReadings(id);
List<GrowPhoto> photos = growCalendarService.getPhotos(id);
List<FeedingLog> feedings = growCalendarService.getFeedingLogs(id);
GrowEntryDetailResponse detail = new GrowEntryDetailResponse(
entry.getId(), entry.getName(), entry.getStrainId(), entry.getStatus(),
entry.getStartedAt(), entry.getExpectedHarvestAt(), entry.getActualHarvestAt(),
entry.getHarvestedGrams(), entry.getLinkedBatchId(), entry.getNotes(),
stages.stream().map(this::toStageResponse).toList(),
sensors.stream().map(this::toSensorResponse).toList(),
photos.stream().map(this::toPhotoResponse).toList(),
feedings.stream().map(this::toFeedingResponse).toList()
);
return ResponseEntity.ok(detail);
}
@PutMapping("/{id}/stage")
@Operation(summary = "Advance to next stage")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).RECORD_STOCK_IN)")
public ResponseEntity<GrowEntryResponse> advanceStage(@PathVariable UUID id, @Valid @RequestBody AdvanceStageRequest request) {
GrowEntry entry = growCalendarService.advanceStage(id, request.stage());
return ResponseEntity.ok(toResponse(entry));
}
@PostMapping("/{id}/sensors")
@Operation(summary = "Add sensor reading")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).RECORD_STOCK_IN)")
public ResponseEntity<SensorReadingResponse> addSensorReading(@PathVariable UUID id, @Valid @RequestBody AddSensorReadingRequest request) {
SensorReading reading = growCalendarService.addSensorReading(id, request.readingType(), request.value(), request.unit());
return ResponseEntity.status(HttpStatus.CREATED).body(toSensorResponse(reading));
}
@PostMapping("/{id}/photos")
@Operation(summary = "Add photo")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).RECORD_STOCK_IN)")
public ResponseEntity<GrowPhotoResponse> addPhoto(@PathVariable UUID id, @Valid @RequestBody AddPhotoRequest request) {
GrowPhoto photo = growCalendarService.addPhoto(id, request.filePath(), request.caption());
return ResponseEntity.status(HttpStatus.CREATED).body(toPhotoResponse(photo));
}
@PostMapping("/{id}/feedings")
@Operation(summary = "Add feeding log")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).RECORD_STOCK_IN)")
public ResponseEntity<FeedingLogResponse> addFeedingLog(@PathVariable UUID id, @Valid @RequestBody AddFeedingLogRequest request) {
FeedingLog feeding = growCalendarService.addFeedingLog(id,
request.nutrientName(), request.amountMl(), request.waterLiters(),
request.phAfter(), request.ecAfter(), request.notes());
return ResponseEntity.status(HttpStatus.CREATED).body(toFeedingResponse(feeding));
}
@PutMapping("/{id}/harvest")
@Operation(summary = "Complete harvest and link to batch")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).RECORD_STOCK_IN)")
public ResponseEntity<GrowEntryResponse> completeHarvest(@PathVariable UUID id, @Valid @RequestBody CompleteHarvestRequest request) {
GrowEntry entry = growCalendarService.completeHarvest(id, request.harvestedGrams(), request.linkedBatchId());
return ResponseEntity.ok(toResponse(entry));
}
// --- Mapping helpers ---
private GrowEntryResponse toResponse(GrowEntry e) {
return new GrowEntryResponse(e.getId(), e.getName(), e.getStrainId(), e.getStatus(),
e.getStartedAt(), e.getExpectedHarvestAt(), e.getActualHarvestAt(),
e.getHarvestedGrams(), e.getLinkedBatchId(), e.getNotes());
}
private GrowStageLogResponse toStageResponse(GrowStageLog s) {
return new GrowStageLogResponse(s.getId(), s.getStage(), s.getStartedAt(), s.getEndedAt(), s.getNotes());
}
private SensorReadingResponse toSensorResponse(SensorReading r) {
return new SensorReadingResponse(r.getId(), r.getReadingType(), r.getValue(), r.getUnit(), r.getRecordedAt());
}
private GrowPhotoResponse toPhotoResponse(GrowPhoto p) {
return new GrowPhotoResponse(p.getId(), p.getFilePath(), p.getCaption(), p.getTakenAt());
}
private FeedingLogResponse toFeedingResponse(FeedingLog f) {
return new FeedingLogResponse(f.getId(), f.getNutrientName(), f.getAmountMl(),
f.getWaterLiters(), f.getPhAfter(), f.getEcAfter(), f.getFedAt(), f.getNotes());
}
}
@@ -0,0 +1,15 @@
package de.cannamanage.api.dto.grow;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.math.BigDecimal;
public record AddFeedingLogRequest(
@NotBlank String nutrientName,
@NotNull BigDecimal amountMl,
BigDecimal waterLiters,
BigDecimal phAfter,
BigDecimal ecAfter,
String notes
) {}
@@ -0,0 +1,8 @@
package de.cannamanage.api.dto.grow;
import jakarta.validation.constraints.NotBlank;
public record AddPhotoRequest(
@NotBlank String filePath,
String caption
) {}
@@ -0,0 +1,13 @@
package de.cannamanage.api.dto.grow;
import de.cannamanage.domain.enums.SensorReadingType;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.math.BigDecimal;
public record AddSensorReadingRequest(
@NotNull SensorReadingType readingType,
@NotNull BigDecimal value,
@NotBlank String unit
) {}
@@ -0,0 +1,8 @@
package de.cannamanage.api.dto.grow;
import de.cannamanage.domain.enums.GrowStage;
import jakarta.validation.constraints.NotNull;
public record AdvanceStageRequest(
@NotNull GrowStage stage
) {}
@@ -0,0 +1,11 @@
package de.cannamanage.api.dto.grow;
import jakarta.validation.constraints.NotNull;
import java.math.BigDecimal;
import java.util.UUID;
public record CompleteHarvestRequest(
@NotNull BigDecimal harvestedGrams,
UUID linkedBatchId
) {}
@@ -0,0 +1,13 @@
package de.cannamanage.api.dto.grow;
import jakarta.validation.constraints.NotBlank;
import java.time.Instant;
import java.util.UUID;
public record CreateGrowEntryRequest(
@NotBlank String name,
UUID strainId,
String notes,
Instant expectedHarvestAt
) {}
@@ -0,0 +1,16 @@
package de.cannamanage.api.dto.grow;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.UUID;
public record FeedingLogResponse(
UUID id,
String nutrientName,
BigDecimal amountMl,
BigDecimal waterLiters,
BigDecimal phAfter,
BigDecimal ecAfter,
Instant fedAt,
String notes
) {}
@@ -0,0 +1,25 @@
package de.cannamanage.api.dto.grow;
import de.cannamanage.domain.enums.GrowStage;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
public record GrowEntryDetailResponse(
UUID id,
String name,
UUID strainId,
GrowStage status,
Instant startedAt,
Instant expectedHarvestAt,
Instant actualHarvestAt,
BigDecimal harvestedGrams,
UUID linkedBatchId,
String notes,
List<GrowStageLogResponse> stages,
List<SensorReadingResponse> sensors,
List<GrowPhotoResponse> photos,
List<FeedingLogResponse> feedings
) {}
@@ -0,0 +1,20 @@
package de.cannamanage.api.dto.grow;
import de.cannamanage.domain.enums.GrowStage;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.UUID;
public record GrowEntryResponse(
UUID id,
String name,
UUID strainId,
GrowStage status,
Instant startedAt,
Instant expectedHarvestAt,
Instant actualHarvestAt,
BigDecimal harvestedGrams,
UUID linkedBatchId,
String notes
) {}
@@ -0,0 +1,11 @@
package de.cannamanage.api.dto.grow;
import java.time.Instant;
import java.util.UUID;
public record GrowPhotoResponse(
UUID id,
String filePath,
String caption,
Instant takenAt
) {}
@@ -0,0 +1,14 @@
package de.cannamanage.api.dto.grow;
import de.cannamanage.domain.enums.GrowStage;
import java.time.Instant;
import java.util.UUID;
public record GrowStageLogResponse(
UUID id,
GrowStage stage,
Instant startedAt,
Instant endedAt,
String notes
) {}
@@ -0,0 +1,15 @@
package de.cannamanage.api.dto.grow;
import de.cannamanage.domain.enums.SensorReadingType;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.UUID;
public record SensorReadingResponse(
UUID id,
SensorReadingType readingType,
BigDecimal value,
String unit,
Instant recordedAt
) {}
@@ -0,0 +1,65 @@
-- Grow entries (one per plant batch lifecycle)
CREATE TABLE IF NOT EXISTS grow_entries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
strain_id UUID REFERENCES strains(id),
status VARCHAR(30) NOT NULL DEFAULT 'SEEDLING',
started_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
expected_harvest_at TIMESTAMP WITH TIME ZONE,
actual_harvest_at TIMESTAMP WITH TIME ZONE,
harvested_grams NUMERIC(8,1),
linked_batch_id UUID REFERENCES batches(id),
notes TEXT,
tenant_id UUID NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Grow stage transitions
CREATE TABLE IF NOT EXISTS grow_stage_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
grow_entry_id UUID NOT NULL REFERENCES grow_entries(id) ON DELETE CASCADE,
stage VARCHAR(30) NOT NULL,
started_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
ended_at TIMESTAMP WITH TIME ZONE,
notes TEXT
);
-- Sensor readings
CREATE TABLE IF NOT EXISTS sensor_readings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
grow_entry_id UUID NOT NULL REFERENCES grow_entries(id) ON DELETE CASCADE,
reading_type VARCHAR(30) NOT NULL,
value NUMERIC(8,2) NOT NULL,
unit VARCHAR(10) NOT NULL,
recorded_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- Photos
CREATE TABLE IF NOT EXISTS grow_photos (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
grow_entry_id UUID NOT NULL REFERENCES grow_entries(id) ON DELETE CASCADE,
file_path VARCHAR(500) NOT NULL,
caption VARCHAR(255),
taken_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- Feeding schedule entries
CREATE TABLE IF NOT EXISTS feeding_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
grow_entry_id UUID NOT NULL REFERENCES grow_entries(id) ON DELETE CASCADE,
nutrient_name VARCHAR(100) NOT NULL,
amount_ml NUMERIC(8,1) NOT NULL,
water_liters NUMERIC(8,1),
ph_after NUMERIC(4,2),
ec_after NUMERIC(6,2),
fed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
notes TEXT
);
CREATE INDEX idx_grow_entries_tenant ON grow_entries(tenant_id);
CREATE INDEX idx_grow_entries_status ON grow_entries(status);
CREATE INDEX idx_sensor_readings_entry ON sensor_readings(grow_entry_id, recorded_at DESC);
CREATE INDEX idx_feeding_logs_entry ON feeding_logs(grow_entry_id, fed_at DESC);
CREATE INDEX idx_grow_photos_entry ON grow_photos(grow_entry_id);
CREATE INDEX idx_grow_stage_logs_entry ON grow_stage_logs(grow_entry_id);