1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 19:30:16 +01:00

feat(production): Produktionsschritte zum Rezept verwalten + AuthorizationPort

Erweitert das Recipe-Aggregate um ProductionStep-Child-Entities (Add/Remove)
mit vollständiger DDD-Konformität. Führt AuthorizationPort-Prüfung in allen
Production Use Cases ein (analog zum usermanagement-Referenz-BC).

Fixes: Request-Validierung (Size, Min/Max), Error-Code-Konsistenz,
Defense-in-Depth für durationMinutes und temperatureCelsius.
This commit is contained in:
Sebastian Frick 2026-02-19 17:37:18 +01:00
parent c26d72fbe7
commit cf93b847e5
27 changed files with 978 additions and 18 deletions

View file

@ -0,0 +1,58 @@
package de.effigenix.application.production;
import de.effigenix.application.production.command.AddProductionStepCommand;
import de.effigenix.domain.production.*;
import de.effigenix.shared.common.Result;
import de.effigenix.shared.security.ActorId;
import de.effigenix.shared.security.AuthorizationPort;
import org.springframework.transaction.annotation.Transactional;
@Transactional
public class AddProductionStep {
private final RecipeRepository recipeRepository;
private final AuthorizationPort authorizationPort;
public AddProductionStep(RecipeRepository recipeRepository, AuthorizationPort authorizationPort) {
this.recipeRepository = recipeRepository;
this.authorizationPort = authorizationPort;
}
public Result<RecipeError, Recipe> execute(AddProductionStepCommand cmd, ActorId performedBy) {
if (!authorizationPort.can(performedBy, ProductionAction.RECIPE_WRITE)) {
return Result.failure(new RecipeError.Unauthorized("Not authorized to modify recipes"));
}
var recipeId = RecipeId.of(cmd.recipeId());
Recipe recipe;
switch (recipeRepository.findById(recipeId)) {
case Result.Failure(var err) ->
{ return Result.failure(new RecipeError.RepositoryFailure(err.message())); }
case Result.Success(var opt) -> {
if (opt.isEmpty()) {
return Result.failure(new RecipeError.RecipeNotFound(recipeId));
}
recipe = opt.get();
}
}
var draft = new ProductionStepDraft(
cmd.stepNumber(), cmd.description(),
cmd.durationMinutes(), cmd.temperatureCelsius()
);
switch (recipe.addProductionStep(draft)) {
case Result.Failure(var err) -> { return Result.failure(err); }
case Result.Success(var ignored) -> { }
}
switch (recipeRepository.save(recipe)) {
case Result.Failure(var err) ->
{ return Result.failure(new RecipeError.RepositoryFailure(err.message())); }
case Result.Success(var ignored) -> { }
}
return Result.success(recipe);
}
}

View file

@ -4,18 +4,25 @@ import de.effigenix.application.production.command.AddRecipeIngredientCommand;
import de.effigenix.domain.production.*;
import de.effigenix.shared.common.Result;
import de.effigenix.shared.security.ActorId;
import de.effigenix.shared.security.AuthorizationPort;
import org.springframework.transaction.annotation.Transactional;
@Transactional
public class AddRecipeIngredient {
private final RecipeRepository recipeRepository;
private final AuthorizationPort authorizationPort;
public AddRecipeIngredient(RecipeRepository recipeRepository) {
public AddRecipeIngredient(RecipeRepository recipeRepository, AuthorizationPort authorizationPort) {
this.recipeRepository = recipeRepository;
this.authorizationPort = authorizationPort;
}
public Result<RecipeError, Recipe> execute(AddRecipeIngredientCommand cmd, ActorId performedBy) {
if (!authorizationPort.can(performedBy, ProductionAction.RECIPE_WRITE)) {
return Result.failure(new RecipeError.Unauthorized("Not authorized to modify recipes"));
}
var recipeId = RecipeId.of(cmd.recipeId());
Recipe recipe;

View file

@ -4,18 +4,25 @@ import de.effigenix.application.production.command.CreateRecipeCommand;
import de.effigenix.domain.production.*;
import de.effigenix.shared.common.Result;
import de.effigenix.shared.security.ActorId;
import de.effigenix.shared.security.AuthorizationPort;
import org.springframework.transaction.annotation.Transactional;
@Transactional
public class CreateRecipe {
private final RecipeRepository recipeRepository;
private final AuthorizationPort authorizationPort;
public CreateRecipe(RecipeRepository recipeRepository) {
public CreateRecipe(RecipeRepository recipeRepository, AuthorizationPort authorizationPort) {
this.recipeRepository = recipeRepository;
this.authorizationPort = authorizationPort;
}
public Result<RecipeError, Recipe> execute(CreateRecipeCommand cmd, ActorId performedBy) {
if (!authorizationPort.can(performedBy, ProductionAction.RECIPE_WRITE)) {
return Result.failure(new RecipeError.Unauthorized("Not authorized to create recipes"));
}
var draft = new RecipeDraft(
cmd.name(), cmd.version(), cmd.type(), cmd.description(),
cmd.yieldPercentage(), cmd.shelfLifeDays(),

View file

@ -0,0 +1,53 @@
package de.effigenix.application.production;
import de.effigenix.application.production.command.RemoveProductionStepCommand;
import de.effigenix.domain.production.*;
import de.effigenix.shared.common.Result;
import de.effigenix.shared.security.ActorId;
import de.effigenix.shared.security.AuthorizationPort;
import org.springframework.transaction.annotation.Transactional;
@Transactional
public class RemoveProductionStep {
private final RecipeRepository recipeRepository;
private final AuthorizationPort authorizationPort;
public RemoveProductionStep(RecipeRepository recipeRepository, AuthorizationPort authorizationPort) {
this.recipeRepository = recipeRepository;
this.authorizationPort = authorizationPort;
}
public Result<RecipeError, Recipe> execute(RemoveProductionStepCommand cmd, ActorId performedBy) {
if (!authorizationPort.can(performedBy, ProductionAction.RECIPE_WRITE)) {
return Result.failure(new RecipeError.Unauthorized("Not authorized to modify recipes"));
}
var recipeId = RecipeId.of(cmd.recipeId());
Recipe recipe;
switch (recipeRepository.findById(recipeId)) {
case Result.Failure(var err) ->
{ return Result.failure(new RecipeError.RepositoryFailure(err.message())); }
case Result.Success(var opt) -> {
if (opt.isEmpty()) {
return Result.failure(new RecipeError.RecipeNotFound(recipeId));
}
recipe = opt.get();
}
}
switch (recipe.removeProductionStep(cmd.stepNumber())) {
case Result.Failure(var err) -> { return Result.failure(err); }
case Result.Success(var ignored) -> { }
}
switch (recipeRepository.save(recipe)) {
case Result.Failure(var err) ->
{ return Result.failure(new RecipeError.RepositoryFailure(err.message())); }
case Result.Success(var ignored) -> { }
}
return Result.success(recipe);
}
}

View file

@ -4,18 +4,25 @@ import de.effigenix.application.production.command.RemoveRecipeIngredientCommand
import de.effigenix.domain.production.*;
import de.effigenix.shared.common.Result;
import de.effigenix.shared.security.ActorId;
import de.effigenix.shared.security.AuthorizationPort;
import org.springframework.transaction.annotation.Transactional;
@Transactional
public class RemoveRecipeIngredient {
private final RecipeRepository recipeRepository;
private final AuthorizationPort authorizationPort;
public RemoveRecipeIngredient(RecipeRepository recipeRepository) {
public RemoveRecipeIngredient(RecipeRepository recipeRepository, AuthorizationPort authorizationPort) {
this.recipeRepository = recipeRepository;
this.authorizationPort = authorizationPort;
}
public Result<RecipeError, Recipe> execute(RemoveRecipeIngredientCommand cmd, ActorId performedBy) {
if (!authorizationPort.can(performedBy, ProductionAction.RECIPE_WRITE)) {
return Result.failure(new RecipeError.Unauthorized("Not authorized to modify recipes"));
}
var recipeId = RecipeId.of(cmd.recipeId());
Recipe recipe;

View file

@ -0,0 +1,9 @@
package de.effigenix.application.production.command;
public record AddProductionStepCommand(
String recipeId,
int stepNumber,
String description,
Integer durationMinutes,
Integer temperatureCelsius
) {}

View file

@ -0,0 +1,6 @@
package de.effigenix.application.production.command;
public record RemoveProductionStepCommand(
String recipeId,
int stepNumber
) {}

View file

@ -0,0 +1,80 @@
package de.effigenix.domain.production;
import de.effigenix.shared.common.Result;
/**
* Child entity of Recipe representing a single production step.
*
* Invariants:
* 1. StepNumber must be >= 1
* 2. Description must not be blank
* 3. DurationMinutes must be > 0 if set
* 4. TemperatureCelsius must be between -273 and 1000 if set
*/
public class ProductionStep {
private final ProductionStepId id;
private final int stepNumber;
private final String description;
private final Integer durationMinutes;
private final Integer temperatureCelsius;
private ProductionStep(ProductionStepId id, int stepNumber, String description,
Integer durationMinutes, Integer temperatureCelsius) {
this.id = id;
this.stepNumber = stepNumber;
this.description = description;
this.durationMinutes = durationMinutes;
this.temperatureCelsius = temperatureCelsius;
}
public static Result<RecipeError, ProductionStep> create(ProductionStepDraft draft) {
if (draft.stepNumber() < 1) {
return Result.failure(new RecipeError.ValidationFailure(
"Step number must be >= 1, was: " + draft.stepNumber()));
}
if (draft.description() == null || draft.description().isBlank()) {
return Result.failure(new RecipeError.ValidationFailure("Step description must not be blank"));
}
if (draft.durationMinutes() != null && draft.durationMinutes() <= 0) {
return Result.failure(new RecipeError.ValidationFailure(
"Duration minutes must be > 0, was: " + draft.durationMinutes()));
}
if (draft.temperatureCelsius() != null
&& (draft.temperatureCelsius() < -273 || draft.temperatureCelsius() > 1000)) {
return Result.failure(new RecipeError.ValidationFailure(
"Temperature must be between -273 and 1000 Celsius, was: " + draft.temperatureCelsius()));
}
return Result.success(new ProductionStep(
ProductionStepId.generate(), draft.stepNumber(), draft.description(),
draft.durationMinutes(), draft.temperatureCelsius()
));
}
public static ProductionStep reconstitute(ProductionStepId id, int stepNumber, String description,
Integer durationMinutes, Integer temperatureCelsius) {
return new ProductionStep(id, stepNumber, description, durationMinutes, temperatureCelsius);
}
public ProductionStepId id() { return id; }
public int stepNumber() { return stepNumber; }
public String description() { return description; }
public Integer durationMinutes() { return durationMinutes; }
public Integer temperatureCelsius() { return temperatureCelsius; }
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (!(obj instanceof ProductionStep other)) return false;
return id.equals(other.id);
}
@Override
public int hashCode() {
return id.hashCode();
}
}

View file

@ -0,0 +1,17 @@
package de.effigenix.domain.production;
/**
* Draft for adding a production step to a recipe. Application layer builds this from raw command inputs.
* The ProductionStep entity validates internally.
*
* @param stepNumber Step number in the recipe (required, >= 1)
* @param description Description of what to do in this step (required)
* @param durationMinutes Optional duration in minutes
* @param temperatureCelsius Optional temperature in Celsius
*/
public record ProductionStepDraft(
int stepNumber,
String description,
Integer durationMinutes,
Integer temperatureCelsius
) {}

View file

@ -0,0 +1,20 @@
package de.effigenix.domain.production;
import java.util.UUID;
public record ProductionStepId(String value) {
public ProductionStepId {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("ProductionStepId must not be blank");
}
}
public static ProductionStepId generate() {
return new ProductionStepId(UUID.randomUUID().toString());
}
public static ProductionStepId of(String value) {
return new ProductionStepId(value);
}
}

View file

@ -23,6 +23,8 @@ import static de.effigenix.shared.common.Result.*;
* 6. New recipes always start in DRAFT status
* 7. Ingredients can only be added/removed in DRAFT status
* 8. Ingredient positions must be unique within a recipe
* 9. ProductionSteps can only be added/removed in DRAFT status
* 10. Step numbers must be unique within a recipe
*/
public class Recipe {
@ -36,6 +38,7 @@ public class Recipe {
private Quantity outputQuantity;
private RecipeStatus status;
private final List<Ingredient> ingredients;
private final List<ProductionStep> productionSteps;
private final LocalDateTime createdAt;
private LocalDateTime updatedAt;
@ -50,6 +53,7 @@ public class Recipe {
Quantity outputQuantity,
RecipeStatus status,
List<Ingredient> ingredients,
List<ProductionStep> productionSteps,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
@ -63,6 +67,7 @@ public class Recipe {
this.outputQuantity = outputQuantity;
this.status = status;
this.ingredients = new ArrayList<>(ingredients);
this.productionSteps = new ArrayList<>(productionSteps);
this.createdAt = createdAt;
this.updatedAt = updatedAt;
}
@ -117,7 +122,7 @@ public class Recipe {
return Result.success(new Recipe(
RecipeId.generate(), name, draft.version(), draft.type(),
draft.description(), yieldPercentage, shelfLifeDays, outputQuantity,
RecipeStatus.DRAFT, List.of(), now, now
RecipeStatus.DRAFT, List.of(), List.of(), now, now
));
}
@ -135,11 +140,12 @@ public class Recipe {
Quantity outputQuantity,
RecipeStatus status,
List<Ingredient> ingredients,
List<ProductionStep> productionSteps,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
return new Recipe(id, name, version, type, description,
yieldPercentage, shelfLifeDays, outputQuantity, status, ingredients, createdAt, updatedAt);
yieldPercentage, shelfLifeDays, outputQuantity, status, ingredients, productionSteps, createdAt, updatedAt);
}
// ==================== Ingredient Management ====================
@ -175,6 +181,39 @@ public class Recipe {
return Result.success(null);
}
// ==================== ProductionStep Management ====================
public Result<RecipeError, ProductionStep> addProductionStep(ProductionStepDraft draft) {
if (status != RecipeStatus.DRAFT) {
return Result.failure(new RecipeError.NotInDraftStatus());
}
if (hasStepNumber(draft.stepNumber())) {
return Result.failure(new RecipeError.DuplicateStepNumber(draft.stepNumber()));
}
ProductionStep step;
switch (ProductionStep.create(draft)) {
case Failure(var err) -> { return Result.failure(err); }
case Success(var val) -> step = val;
}
this.productionSteps.add(step);
touch();
return Result.success(step);
}
public Result<RecipeError, Void> removeProductionStep(int stepNumber) {
if (status != RecipeStatus.DRAFT) {
return Result.failure(new RecipeError.NotInDraftStatus());
}
boolean removed = this.productionSteps.removeIf(s -> s.stepNumber() == stepNumber);
if (!removed) {
return Result.failure(new RecipeError.StepNotFound(stepNumber));
}
touch();
return Result.success(null);
}
// ==================== Getters ====================
public RecipeId id() { return id; }
@ -187,6 +226,7 @@ public class Recipe {
public Quantity outputQuantity() { return outputQuantity; }
public RecipeStatus status() { return status; }
public List<Ingredient> ingredients() { return Collections.unmodifiableList(ingredients); }
public List<ProductionStep> productionSteps() { return Collections.unmodifiableList(productionSteps); }
public LocalDateTime createdAt() { return createdAt; }
public LocalDateTime updatedAt() { return updatedAt; }
@ -196,6 +236,10 @@ public class Recipe {
return ingredients.stream().anyMatch(i -> i.position() == position);
}
private boolean hasStepNumber(int stepNumber) {
return productionSteps.stream().anyMatch(s -> s.stepNumber() == stepNumber);
}
private void touch() {
this.updatedAt = LocalDateTime.now();
}

View file

@ -35,10 +35,20 @@ public sealed interface RecipeError {
}
record IngredientNotFound(IngredientId id) implements RecipeError {
@Override public String code() { return "INGREDIENT_NOT_FOUND"; }
@Override public String code() { return "RECIPE_INGREDIENT_NOT_FOUND"; }
@Override public String message() { return "Ingredient with ID '" + id.value() + "' not found"; }
}
record DuplicateStepNumber(int stepNumber) implements RecipeError {
@Override public String code() { return "RECIPE_DUPLICATE_STEP_NUMBER"; }
@Override public String message() { return "Step number " + stepNumber + " is already taken"; }
}
record StepNotFound(int stepNumber) implements RecipeError {
@Override public String code() { return "RECIPE_STEP_NOT_FOUND"; }
@Override public String message() { return "Production step with number " + stepNumber + " not found"; }
}
record Unauthorized(String message) implements RecipeError {
@Override public String code() { return "UNAUTHORIZED"; }
}

View file

@ -1,9 +1,12 @@
package de.effigenix.infrastructure.config;
import de.effigenix.application.production.AddProductionStep;
import de.effigenix.application.production.AddRecipeIngredient;
import de.effigenix.application.production.CreateRecipe;
import de.effigenix.application.production.RemoveProductionStep;
import de.effigenix.application.production.RemoveRecipeIngredient;
import de.effigenix.domain.production.RecipeRepository;
import de.effigenix.shared.security.AuthorizationPort;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@ -11,17 +14,27 @@ import org.springframework.context.annotation.Configuration;
public class ProductionUseCaseConfiguration {
@Bean
public CreateRecipe createRecipe(RecipeRepository recipeRepository) {
return new CreateRecipe(recipeRepository);
public CreateRecipe createRecipe(RecipeRepository recipeRepository, AuthorizationPort authorizationPort) {
return new CreateRecipe(recipeRepository, authorizationPort);
}
@Bean
public AddRecipeIngredient addRecipeIngredient(RecipeRepository recipeRepository) {
return new AddRecipeIngredient(recipeRepository);
public AddRecipeIngredient addRecipeIngredient(RecipeRepository recipeRepository, AuthorizationPort authorizationPort) {
return new AddRecipeIngredient(recipeRepository, authorizationPort);
}
@Bean
public RemoveRecipeIngredient removeRecipeIngredient(RecipeRepository recipeRepository) {
return new RemoveRecipeIngredient(recipeRepository);
public RemoveRecipeIngredient removeRecipeIngredient(RecipeRepository recipeRepository, AuthorizationPort authorizationPort) {
return new RemoveRecipeIngredient(recipeRepository, authorizationPort);
}
@Bean
public AddProductionStep addProductionStep(RecipeRepository recipeRepository, AuthorizationPort authorizationPort) {
return new AddProductionStep(recipeRepository, authorizationPort);
}
@Bean
public RemoveProductionStep removeProductionStep(RecipeRepository recipeRepository, AuthorizationPort authorizationPort) {
return new RemoveProductionStep(recipeRepository, authorizationPort);
}
}

View file

@ -0,0 +1,55 @@
package de.effigenix.infrastructure.production.persistence.entity;
import jakarta.persistence.*;
@Entity
@Table(name = "recipe_production_steps",
uniqueConstraints = @UniqueConstraint(name = "uq_recipe_step_number", columnNames = {"recipe_id", "step_number"}))
public class ProductionStepEntity {
@Id
@Column(name = "id", nullable = false, length = 36)
private String id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "recipe_id", nullable = false)
private RecipeEntity recipe;
@Column(name = "step_number", nullable = false)
private int stepNumber;
@Column(name = "description", nullable = false, length = 500)
private String description;
@Column(name = "duration_minutes")
private Integer durationMinutes;
@Column(name = "temperature_celsius")
private Integer temperatureCelsius;
protected ProductionStepEntity() {}
public ProductionStepEntity(String id, RecipeEntity recipe, int stepNumber, String description,
Integer durationMinutes, Integer temperatureCelsius) {
this.id = id;
this.recipe = recipe;
this.stepNumber = stepNumber;
this.description = description;
this.durationMinutes = durationMinutes;
this.temperatureCelsius = temperatureCelsius;
}
public String getId() { return id; }
public RecipeEntity getRecipe() { return recipe; }
public int getStepNumber() { return stepNumber; }
public String getDescription() { return description; }
public Integer getDurationMinutes() { return durationMinutes; }
public Integer getTemperatureCelsius() { return temperatureCelsius; }
public void setId(String id) { this.id = id; }
public void setRecipe(RecipeEntity recipe) { this.recipe = recipe; }
public void setStepNumber(int stepNumber) { this.stepNumber = stepNumber; }
public void setDescription(String description) { this.description = description; }
public void setDurationMinutes(Integer durationMinutes) { this.durationMinutes = durationMinutes; }
public void setTemperatureCelsius(Integer temperatureCelsius) { this.temperatureCelsius = temperatureCelsius; }
}

View file

@ -53,6 +53,10 @@ public class RecipeEntity {
@OrderBy("position ASC")
private List<IngredientEntity> ingredients = new ArrayList<>();
@OneToMany(mappedBy = "recipe", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER)
@OrderBy("stepNumber ASC")
private List<ProductionStepEntity> productionSteps = new ArrayList<>();
protected RecipeEntity() {}
public RecipeEntity(String id, String name, int version, String type, String description,
@ -85,6 +89,7 @@ public class RecipeEntity {
public LocalDateTime getCreatedAt() { return createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public List<IngredientEntity> getIngredients() { return ingredients; }
public List<ProductionStepEntity> getProductionSteps() { return productionSteps; }
public void setId(String id) { this.id = id; }
public void setName(String name) { this.name = name; }
@ -99,4 +104,5 @@ public class RecipeEntity {
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
public void setIngredients(List<IngredientEntity> ingredients) { this.ingredients = ingredients; }
public void setProductionSteps(List<ProductionStepEntity> productionSteps) { this.productionSteps = productionSteps; }
}

View file

@ -2,6 +2,7 @@ package de.effigenix.infrastructure.production.persistence.mapper;
import de.effigenix.domain.production.*;
import de.effigenix.infrastructure.production.persistence.entity.IngredientEntity;
import de.effigenix.infrastructure.production.persistence.entity.ProductionStepEntity;
import de.effigenix.infrastructure.production.persistence.entity.RecipeEntity;
import org.springframework.stereotype.Component;
@ -32,6 +33,11 @@ public class RecipeMapper {
.collect(Collectors.toList());
entity.setIngredients(ingredientEntities);
List<ProductionStepEntity> stepEntities = recipe.productionSteps().stream()
.map(s -> toProductionStepEntity(s, entity))
.collect(Collectors.toList());
entity.setProductionSteps(stepEntities);
return entity;
}
@ -40,6 +46,10 @@ public class RecipeMapper {
.map(this::toDomainIngredient)
.collect(Collectors.toList());
List<ProductionStep> productionSteps = entity.getProductionSteps().stream()
.map(this::toDomainProductionStep)
.collect(Collectors.toList());
return Recipe.reconstitute(
RecipeId.of(entity.getId()),
new RecipeName(entity.getName()),
@ -55,6 +65,7 @@ public class RecipeMapper {
),
RecipeStatus.valueOf(entity.getStatus()),
ingredients,
productionSteps,
entity.getCreatedAt(),
entity.getUpdatedAt()
);
@ -73,6 +84,27 @@ public class RecipeMapper {
);
}
private ProductionStepEntity toProductionStepEntity(ProductionStep step, RecipeEntity recipe) {
return new ProductionStepEntity(
step.id().value(),
recipe,
step.stepNumber(),
step.description(),
step.durationMinutes(),
step.temperatureCelsius()
);
}
private ProductionStep toDomainProductionStep(ProductionStepEntity entity) {
return ProductionStep.reconstitute(
ProductionStepId.of(entity.getId()),
entity.getStepNumber(),
entity.getDescription(),
entity.getDurationMinutes(),
entity.getTemperatureCelsius()
);
}
private Ingredient toDomainIngredient(IngredientEntity entity) {
return Ingredient.reconstitute(
IngredientId.of(entity.getId()),

View file

@ -1,12 +1,17 @@
package de.effigenix.infrastructure.production.web.controller;
import de.effigenix.application.production.AddProductionStep;
import de.effigenix.application.production.AddRecipeIngredient;
import de.effigenix.application.production.CreateRecipe;
import de.effigenix.application.production.RemoveProductionStep;
import de.effigenix.application.production.RemoveRecipeIngredient;
import de.effigenix.application.production.command.AddProductionStepCommand;
import de.effigenix.application.production.command.AddRecipeIngredientCommand;
import de.effigenix.application.production.command.CreateRecipeCommand;
import de.effigenix.application.production.command.RemoveProductionStepCommand;
import de.effigenix.application.production.command.RemoveRecipeIngredientCommand;
import de.effigenix.domain.production.RecipeError;
import de.effigenix.infrastructure.production.web.dto.AddProductionStepRequest;
import de.effigenix.infrastructure.production.web.dto.AddRecipeIngredientRequest;
import de.effigenix.infrastructure.production.web.dto.CreateRecipeRequest;
import de.effigenix.infrastructure.production.web.dto.RecipeResponse;
@ -33,13 +38,19 @@ public class RecipeController {
private final CreateRecipe createRecipe;
private final AddRecipeIngredient addRecipeIngredient;
private final RemoveRecipeIngredient removeRecipeIngredient;
private final AddProductionStep addProductionStep;
private final RemoveProductionStep removeProductionStep;
public RecipeController(CreateRecipe createRecipe,
AddRecipeIngredient addRecipeIngredient,
RemoveRecipeIngredient removeRecipeIngredient) {
RemoveRecipeIngredient removeRecipeIngredient,
AddProductionStep addProductionStep,
RemoveProductionStep removeProductionStep) {
this.createRecipe = createRecipe;
this.addRecipeIngredient = addRecipeIngredient;
this.removeRecipeIngredient = removeRecipeIngredient;
this.addProductionStep = addProductionStep;
this.removeProductionStep = removeProductionStep;
}
@PostMapping
@ -111,6 +122,51 @@ public class RecipeController {
return ResponseEntity.noContent().build();
}
@PostMapping("/{id}/steps")
@PreAuthorize("hasAuthority('RECIPE_WRITE')")
public ResponseEntity<RecipeResponse> addProductionStep(
@PathVariable("id") String recipeId,
@Valid @RequestBody AddProductionStepRequest request,
Authentication authentication
) {
var actorId = extractActorId(authentication);
logger.info("Adding production step to recipe: {} by actor: {}", recipeId, actorId.value());
var cmd = new AddProductionStepCommand(
recipeId, request.stepNumber(), request.description(),
request.durationMinutes(), request.temperatureCelsius()
);
var result = addProductionStep.execute(cmd, actorId);
if (result.isFailure()) {
throw new RecipeDomainErrorException(result.unsafeGetError());
}
logger.info("Production step added to recipe: {}", recipeId);
return ResponseEntity.status(HttpStatus.CREATED).body(RecipeResponse.from(result.unsafeGetValue()));
}
@DeleteMapping("/{id}/steps/{stepNumber}")
@PreAuthorize("hasAuthority('RECIPE_WRITE')")
public ResponseEntity<Void> removeProductionStep(
@PathVariable("id") String recipeId,
@PathVariable("stepNumber") int stepNumber,
Authentication authentication
) {
var actorId = extractActorId(authentication);
logger.info("Removing production step {} from recipe: {} by actor: {}", stepNumber, recipeId, actorId.value());
var cmd = new RemoveProductionStepCommand(recipeId, stepNumber);
var result = removeProductionStep.execute(cmd, actorId);
if (result.isFailure()) {
throw new RecipeDomainErrorException(result.unsafeGetError());
}
logger.info("Production step removed from recipe: {}", recipeId);
return ResponseEntity.noContent().build();
}
private ActorId extractActorId(Authentication authentication) {
if (authentication == null || authentication.getName() == null) {
throw new IllegalStateException("No authentication found in SecurityContext");

View file

@ -0,0 +1,13 @@
package de.effigenix.infrastructure.production.web.dto;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record AddProductionStepRequest(
@Min(1) int stepNumber,
@NotBlank @Size(max = 500) String description,
@Min(1) Integer durationMinutes,
@Min(-273) @Max(1000) Integer temperatureCelsius
) {}

View file

@ -0,0 +1,23 @@
package de.effigenix.infrastructure.production.web.dto;
import de.effigenix.domain.production.ProductionStep;
import io.swagger.v3.oas.annotations.media.Schema;
@Schema(requiredProperties = {"id", "stepNumber", "description"})
public record ProductionStepResponse(
String id,
int stepNumber,
String description,
@Schema(nullable = true) Integer durationMinutes,
@Schema(nullable = true) Integer temperatureCelsius
) {
public static ProductionStepResponse from(ProductionStep step) {
return new ProductionStepResponse(
step.id().value(),
step.stepNumber(),
step.description(),
step.durationMinutes(),
step.temperatureCelsius()
);
}
}

View file

@ -6,7 +6,7 @@ import io.swagger.v3.oas.annotations.media.Schema;
import java.time.LocalDateTime;
import java.util.List;
@Schema(requiredProperties = {"id", "name", "version", "type", "description", "yieldPercentage", "outputQuantity", "outputUom", "status", "ingredients", "createdAt", "updatedAt"})
@Schema(requiredProperties = {"id", "name", "version", "type", "description", "yieldPercentage", "outputQuantity", "outputUom", "status", "ingredients", "productionSteps", "createdAt", "updatedAt"})
public record RecipeResponse(
String id,
String name,
@ -19,6 +19,7 @@ public record RecipeResponse(
String outputUom,
String status,
List<IngredientResponse> ingredients,
List<ProductionStepResponse> productionSteps,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
@ -35,6 +36,7 @@ public record RecipeResponse(
recipe.outputQuantity().uom().name(),
recipe.status().name(),
recipe.ingredients().stream().map(IngredientResponse::from).toList(),
recipe.productionSteps().stream().map(ProductionStepResponse::from).toList(),
recipe.createdAt(),
recipe.updatedAt()
);

View file

@ -10,8 +10,10 @@ public final class ProductionErrorHttpStatusMapper {
return switch (error) {
case RecipeError.RecipeNotFound e -> 404;
case RecipeError.IngredientNotFound e -> 404;
case RecipeError.StepNotFound e -> 404;
case RecipeError.NameAndVersionAlreadyExists e -> 409;
case RecipeError.DuplicatePosition e -> 409;
case RecipeError.DuplicateStepNumber e -> 409;
case RecipeError.InvalidShelfLife e -> 400;
case RecipeError.ValidationFailure e -> 400;
case RecipeError.NotInDraftStatus e -> 409;

View file

@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
<changeSet id="012-create-recipe-production-steps-table" author="effigenix">
<createTable tableName="recipe_production_steps">
<column name="id" type="VARCHAR(36)">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="recipe_id" type="VARCHAR(36)">
<constraints nullable="false"/>
</column>
<column name="step_number" type="INT">
<constraints nullable="false"/>
</column>
<column name="description" type="VARCHAR(500)">
<constraints nullable="false"/>
</column>
<column name="duration_minutes" type="INT"/>
<column name="temperature_celsius" type="INT"/>
</createTable>
<addForeignKeyConstraint baseTableName="recipe_production_steps" baseColumnNames="recipe_id"
referencedTableName="recipes" referencedColumnNames="id"
constraintName="fk_recipe_production_steps_recipe"
onDelete="CASCADE"/>
<addUniqueConstraint tableName="recipe_production_steps" columnNames="recipe_id, step_number"
constraintName="uq_recipe_step_number"/>
<createIndex tableName="recipe_production_steps" indexName="idx_recipe_production_steps_recipe_id">
<column name="recipe_id"/>
</createIndex>
</changeSet>
</databaseChangeLog>

View file

@ -16,5 +16,6 @@
<include file="db/changelog/changes/009-create-storage-location-schema.xml"/>
<include file="db/changelog/changes/010-create-recipe-schema.xml"/>
<include file="db/changelog/changes/011-create-recipe-ingredients-table.xml"/>
<include file="db/changelog/changes/012-create-recipe-production-steps-table.xml"/>
</databaseChangeLog>