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

feat(production): Rezepte abfragen per ID und nach Status filtern (#31)

GET /api/recipes/{id} liefert vollständiges Rezept inkl. Zutaten und Schritte.
GET /api/recipes?status=ACTIVE liefert Summary-Liste mit ingredientCount/stepCount.
This commit is contained in:
Sebastian Frick 2026-02-19 22:24:47 +01:00
parent 6feb3a9f1c
commit f2003a3093
12 changed files with 661 additions and 0 deletions

View file

@ -0,0 +1,36 @@
package de.effigenix.application.production;
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(readOnly = true)
public class GetRecipe {
private final RecipeRepository recipeRepository;
private final AuthorizationPort authorizationPort;
public GetRecipe(RecipeRepository recipeRepository, AuthorizationPort authorizationPort) {
this.recipeRepository = recipeRepository;
this.authorizationPort = authorizationPort;
}
public Result<RecipeError, Recipe> execute(RecipeId recipeId, ActorId performedBy) {
if (!authorizationPort.can(performedBy, ProductionAction.RECIPE_READ)) {
return Result.failure(new RecipeError.Unauthorized("Not authorized to read recipes"));
}
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));
}
return Result.success(opt.get());
}
}
}
}

View file

@ -0,0 +1,47 @@
package de.effigenix.application.production;
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;
import java.util.List;
@Transactional(readOnly = true)
public class ListRecipes {
private final RecipeRepository recipeRepository;
private final AuthorizationPort authorizationPort;
public ListRecipes(RecipeRepository recipeRepository, AuthorizationPort authorizationPort) {
this.recipeRepository = recipeRepository;
this.authorizationPort = authorizationPort;
}
public Result<RecipeError, List<Recipe>> execute(ActorId performedBy) {
if (!authorizationPort.can(performedBy, ProductionAction.RECIPE_READ)) {
return Result.failure(new RecipeError.Unauthorized("Not authorized to read recipes"));
}
switch (recipeRepository.findAll()) {
case Result.Failure(var err) ->
{ return Result.failure(new RecipeError.RepositoryFailure(err.message())); }
case Result.Success(var recipes) ->
{ return Result.success(recipes); }
}
}
public Result<RecipeError, List<Recipe>> executeByStatus(RecipeStatus status, ActorId performedBy) {
if (!authorizationPort.can(performedBy, ProductionAction.RECIPE_READ)) {
return Result.failure(new RecipeError.Unauthorized("Not authorized to read recipes"));
}
switch (recipeRepository.findByStatus(status)) {
case Result.Failure(var err) ->
{ return Result.failure(new RecipeError.RepositoryFailure(err.message())); }
case Result.Success(var recipes) ->
{ return Result.success(recipes); }
}
}
}

View file

@ -17,4 +17,6 @@ public interface RecipeRepository {
Result<RepositoryError, Void> delete(Recipe recipe);
Result<RepositoryError, Boolean> existsByNameAndVersion(String name, int version);
Result<RepositoryError, List<Recipe>> findByStatus(RecipeStatus status);
}

View file

@ -5,6 +5,8 @@ import de.effigenix.application.production.ArchiveRecipe;
import de.effigenix.application.production.AddProductionStep;
import de.effigenix.application.production.AddRecipeIngredient;
import de.effigenix.application.production.CreateRecipe;
import de.effigenix.application.production.GetRecipe;
import de.effigenix.application.production.ListRecipes;
import de.effigenix.application.production.RemoveProductionStep;
import de.effigenix.application.production.RemoveRecipeIngredient;
import de.effigenix.domain.production.RecipeRepository;
@ -40,6 +42,16 @@ public class ProductionUseCaseConfiguration {
return new RemoveProductionStep(recipeRepository, authorizationPort);
}
@Bean
public GetRecipe getRecipe(RecipeRepository recipeRepository, AuthorizationPort authorizationPort) {
return new GetRecipe(recipeRepository, authorizationPort);
}
@Bean
public ListRecipes listRecipes(RecipeRepository recipeRepository, AuthorizationPort authorizationPort) {
return new ListRecipes(recipeRepository, authorizationPort);
}
@Bean
public ActivateRecipe activateRecipe(RecipeRepository recipeRepository, AuthorizationPort authorizationPort) {
return new ActivateRecipe(recipeRepository, authorizationPort);

View file

@ -78,6 +78,19 @@ public class JpaRecipeRepository implements RecipeRepository {
}
}
@Override
public Result<RepositoryError, List<Recipe>> findByStatus(RecipeStatus status) {
try {
List<Recipe> result = jpaRepository.findByStatus(status.name()).stream()
.map(mapper::toDomain)
.collect(Collectors.toList());
return Result.success(result);
} catch (Exception e) {
logger.trace("Database error in findByStatus", e);
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
}
}
@Override
public Result<RepositoryError, Boolean> existsByNameAndVersion(String name, int version) {
try {

View file

@ -5,6 +5,8 @@ import de.effigenix.application.production.ArchiveRecipe;
import de.effigenix.application.production.AddProductionStep;
import de.effigenix.application.production.AddRecipeIngredient;
import de.effigenix.application.production.CreateRecipe;
import de.effigenix.application.production.GetRecipe;
import de.effigenix.application.production.ListRecipes;
import de.effigenix.application.production.RemoveProductionStep;
import de.effigenix.application.production.RemoveRecipeIngredient;
import de.effigenix.application.production.command.ActivateRecipeCommand;
@ -15,10 +17,13 @@ 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.domain.production.RecipeId;
import de.effigenix.domain.production.RecipeStatus;
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;
import de.effigenix.infrastructure.production.web.dto.RecipeSummaryResponse;
import de.effigenix.shared.security.ActorId;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
@ -31,6 +36,8 @@ import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/recipes")
@SecurityRequirement(name = "Bearer Authentication")
@ -40,6 +47,8 @@ public class RecipeController {
private static final Logger logger = LoggerFactory.getLogger(RecipeController.class);
private final CreateRecipe createRecipe;
private final GetRecipe getRecipe;
private final ListRecipes listRecipes;
private final AddRecipeIngredient addRecipeIngredient;
private final RemoveRecipeIngredient removeRecipeIngredient;
private final AddProductionStep addProductionStep;
@ -48,6 +57,8 @@ public class RecipeController {
private final ArchiveRecipe archiveRecipe;
public RecipeController(CreateRecipe createRecipe,
GetRecipe getRecipe,
ListRecipes listRecipes,
AddRecipeIngredient addRecipeIngredient,
RemoveRecipeIngredient removeRecipeIngredient,
AddProductionStep addProductionStep,
@ -55,6 +66,8 @@ public class RecipeController {
ActivateRecipe activateRecipe,
ArchiveRecipe archiveRecipe) {
this.createRecipe = createRecipe;
this.getRecipe = getRecipe;
this.listRecipes = listRecipes;
this.addRecipeIngredient = addRecipeIngredient;
this.removeRecipeIngredient = removeRecipeIngredient;
this.addProductionStep = addProductionStep;
@ -63,6 +76,54 @@ public class RecipeController {
this.archiveRecipe = archiveRecipe;
}
@GetMapping("/{id}")
@PreAuthorize("hasAuthority('RECIPE_READ')")
public ResponseEntity<RecipeResponse> getRecipe(
@PathVariable("id") String recipeId,
Authentication authentication
) {
var actorId = extractActorId(authentication);
var result = getRecipe.execute(RecipeId.of(recipeId), actorId);
if (result.isFailure()) {
throw new RecipeDomainErrorException(result.unsafeGetError());
}
return ResponseEntity.ok(RecipeResponse.from(result.unsafeGetValue()));
}
@GetMapping
@PreAuthorize("hasAuthority('RECIPE_READ')")
public ResponseEntity<List<RecipeSummaryResponse>> listRecipes(
@RequestParam(value = "status", required = false) String status,
Authentication authentication
) {
var actorId = extractActorId(authentication);
RecipeStatus parsedStatus = null;
if (status != null) {
try {
parsedStatus = RecipeStatus.valueOf(status);
} catch (IllegalArgumentException e) {
throw new RecipeDomainErrorException(
new RecipeError.ValidationFailure("Invalid status: " + status));
}
}
var result = (parsedStatus != null)
? listRecipes.executeByStatus(parsedStatus, actorId)
: listRecipes.execute(actorId);
if (result.isFailure()) {
throw new RecipeDomainErrorException(result.unsafeGetError());
}
var summaries = result.unsafeGetValue().stream()
.map(RecipeSummaryResponse::from)
.toList();
return ResponseEntity.ok(summaries);
}
@PostMapping
@PreAuthorize("hasAuthority('RECIPE_WRITE')")
public ResponseEntity<RecipeResponse> createRecipe(

View file

@ -0,0 +1,43 @@
package de.effigenix.infrastructure.production.web.dto;
import de.effigenix.domain.production.Recipe;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.LocalDateTime;
@Schema(requiredProperties = {"id", "name", "version", "type", "description", "yieldPercentage", "outputQuantity", "outputUom", "status", "ingredientCount", "stepCount", "createdAt", "updatedAt"})
public record RecipeSummaryResponse(
String id,
String name,
int version,
String type,
String description,
int yieldPercentage,
@Schema(nullable = true) Integer shelfLifeDays,
String outputQuantity,
String outputUom,
String status,
int ingredientCount,
int stepCount,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
public static RecipeSummaryResponse from(Recipe recipe) {
return new RecipeSummaryResponse(
recipe.id().value(),
recipe.name().value(),
recipe.version(),
recipe.type().name(),
recipe.description(),
recipe.yieldPercentage().value(),
recipe.shelfLifeDays(),
recipe.outputQuantity().amount().toPlainString(),
recipe.outputQuantity().uom().name(),
recipe.status().name(),
recipe.ingredients().size(),
recipe.productionSteps().size(),
recipe.createdAt(),
recipe.updatedAt()
);
}
}

View file

@ -3,6 +3,7 @@ package de.effigenix.infrastructure.stub;
import de.effigenix.domain.production.Recipe;
import de.effigenix.domain.production.RecipeId;
import de.effigenix.domain.production.RecipeRepository;
import de.effigenix.domain.production.RecipeStatus;
import de.effigenix.shared.common.RepositoryError;
import de.effigenix.shared.common.Result;
import org.springframework.context.annotation.Profile;
@ -42,4 +43,9 @@ public class StubRecipeRepository implements RecipeRepository {
public Result<RepositoryError, Boolean> existsByNameAndVersion(String name, int version) {
return Result.failure(STUB_ERROR);
}
@Override
public Result<RepositoryError, List<Recipe>> findByStatus(RecipeStatus status) {
return Result.failure(STUB_ERROR);
}
}