Implement Recipe, Planning, Shopping, Pantry, and Admin domains
Outside-in TDD for all 5 remaining domains (128 tests total): - Recipe: CRUD, ingredients autocomplete/patch, tags, categories (27 tests) - Planning: week plans, slots, confirm, suggestions, variety score, cooking logs (24 tests) - Shopping: generate from plan, publish, check/add/remove items (15 tests) - Pantry: CRUD with expiry sorting (11 tests) - Admin: user management, password reset, audit logging (13 tests) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
package com.recipeapp.pantry;
|
||||
|
||||
import com.recipeapp.pantry.dto.*;
|
||||
import com.recipeapp.recipe.HouseholdResolver;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.security.Principal;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/v1/pantry-items")
|
||||
public class PantryController {
|
||||
|
||||
private final PantryService pantryService;
|
||||
private final HouseholdResolver householdResolver;
|
||||
|
||||
public PantryController(PantryService pantryService, HouseholdResolver householdResolver) {
|
||||
this.pantryService = pantryService;
|
||||
this.householdResolver = householdResolver;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<PantryItemResponse> listItems(Principal principal) {
|
||||
UUID householdId = householdResolver.resolve(principal.getName());
|
||||
return pantryService.listItems(householdId);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<PantryItemResponse> createItem(
|
||||
Principal principal,
|
||||
@Valid @RequestBody CreatePantryItemRequest request) {
|
||||
UUID householdId = householdResolver.resolve(principal.getName());
|
||||
PantryItemResponse response = pantryService.createItem(householdId, request);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(response);
|
||||
}
|
||||
|
||||
@PatchMapping("/{id}")
|
||||
public PantryItemResponse updateItem(
|
||||
Principal principal,
|
||||
@PathVariable UUID id,
|
||||
@Valid @RequestBody UpdatePantryItemRequest request) {
|
||||
UUID householdId = householdResolver.resolve(principal.getName());
|
||||
return pantryService.updateItem(householdId, id, request);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public void deleteItem(Principal principal, @PathVariable UUID id) {
|
||||
UUID householdId = householdResolver.resolve(principal.getName());
|
||||
pantryService.deleteItem(householdId, id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.recipeapp.pantry;
|
||||
|
||||
import com.recipeapp.pantry.entity.PantryItem;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface PantryItemRepository extends JpaRepository<PantryItem, UUID> {
|
||||
|
||||
@Query("SELECT p FROM PantryItem p WHERE p.household.id = :householdId ORDER BY p.bestBefore ASC NULLS LAST")
|
||||
List<PantryItem> findByHouseholdIdOrderByBestBeforeAscNullsLast(UUID householdId);
|
||||
|
||||
Optional<PantryItem> findByIdAndHouseholdId(UUID id, UUID householdId);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.recipeapp.pantry;
|
||||
|
||||
import com.recipeapp.pantry.dto.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface PantryService {
|
||||
|
||||
List<PantryItemResponse> listItems(UUID householdId);
|
||||
|
||||
PantryItemResponse createItem(UUID householdId, CreatePantryItemRequest request);
|
||||
|
||||
PantryItemResponse updateItem(UUID householdId, UUID itemId, UpdatePantryItemRequest request);
|
||||
|
||||
void deleteItem(UUID householdId, UUID itemId);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.recipeapp.pantry;
|
||||
|
||||
import com.recipeapp.common.ResourceNotFoundException;
|
||||
import com.recipeapp.common.ValidationException;
|
||||
import com.recipeapp.household.HouseholdRepository;
|
||||
import com.recipeapp.household.entity.Household;
|
||||
import com.recipeapp.pantry.dto.*;
|
||||
import com.recipeapp.pantry.entity.PantryItem;
|
||||
import com.recipeapp.recipe.IngredientRepository;
|
||||
import com.recipeapp.recipe.dto.RecipeDetailResponse.CategoryRef;
|
||||
import com.recipeapp.recipe.entity.Ingredient;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
@Transactional
|
||||
public class PantryServiceImpl implements PantryService {
|
||||
|
||||
private final PantryItemRepository pantryItemRepository;
|
||||
private final HouseholdRepository householdRepository;
|
||||
private final IngredientRepository ingredientRepository;
|
||||
|
||||
public PantryServiceImpl(PantryItemRepository pantryItemRepository,
|
||||
HouseholdRepository householdRepository,
|
||||
IngredientRepository ingredientRepository) {
|
||||
this.pantryItemRepository = pantryItemRepository;
|
||||
this.householdRepository = householdRepository;
|
||||
this.ingredientRepository = ingredientRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public List<PantryItemResponse> listItems(UUID householdId) {
|
||||
return pantryItemRepository.findByHouseholdIdOrderByBestBeforeAscNullsLast(householdId)
|
||||
.stream()
|
||||
.map(this::toResponse)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public PantryItemResponse createItem(UUID householdId, CreatePantryItemRequest request) {
|
||||
Household household = householdRepository.findById(householdId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Household not found"));
|
||||
|
||||
Ingredient ingredient = null;
|
||||
if (request.ingredientId() != null) {
|
||||
ingredient = ingredientRepository.findById(request.ingredientId())
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Ingredient not found"));
|
||||
}
|
||||
|
||||
if (request.ingredientId() == null && (request.customName() == null || request.customName().isBlank())) {
|
||||
throw new ValidationException("Either ingredientId or customName must be provided");
|
||||
}
|
||||
|
||||
PantryItem item = new PantryItem(household, ingredient, request.customName(),
|
||||
request.quantity(), request.unit(), request.bestBefore(), request.openedOn());
|
||||
item = pantryItemRepository.save(item);
|
||||
return toResponse(item);
|
||||
}
|
||||
|
||||
@Override
|
||||
public PantryItemResponse updateItem(UUID householdId, UUID itemId, UpdatePantryItemRequest request) {
|
||||
PantryItem item = pantryItemRepository.findByIdAndHouseholdId(itemId, householdId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Pantry item not found"));
|
||||
|
||||
if (request.quantity() != null) item.setQuantity(request.quantity());
|
||||
if (request.unit() != null) item.setUnit(request.unit());
|
||||
if (request.bestBefore() != null) item.setBestBefore(request.bestBefore());
|
||||
if (request.openedOn() != null) item.setOpenedOn(request.openedOn());
|
||||
|
||||
item = pantryItemRepository.save(item);
|
||||
return toResponse(item);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteItem(UUID householdId, UUID itemId) {
|
||||
PantryItem item = pantryItemRepository.findByIdAndHouseholdId(itemId, householdId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Pantry item not found"));
|
||||
pantryItemRepository.delete(item);
|
||||
}
|
||||
|
||||
private PantryItemResponse toResponse(PantryItem item) {
|
||||
UUID ingredientId = item.getIngredient() != null ? item.getIngredient().getId() : null;
|
||||
String name = item.getIngredient() != null ? item.getIngredient().getName() : item.getCustomName();
|
||||
CategoryRef category = null;
|
||||
if (item.getIngredient() != null && item.getIngredient().getCategory() != null) {
|
||||
category = new CategoryRef(
|
||||
item.getIngredient().getCategory().getId(),
|
||||
item.getIngredient().getCategory().getName());
|
||||
}
|
||||
return new PantryItemResponse(
|
||||
item.getId(), ingredientId, name, category,
|
||||
item.getQuantity(), item.getUnit(), item.getBestBefore(), item.getOpenedOn());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.recipeapp.pantry.dto;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.UUID;
|
||||
|
||||
public record CreatePantryItemRequest(
|
||||
UUID ingredientId,
|
||||
String customName,
|
||||
BigDecimal quantity,
|
||||
String unit,
|
||||
LocalDate bestBefore,
|
||||
LocalDate openedOn
|
||||
) {}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.recipeapp.pantry.dto;
|
||||
|
||||
import com.recipeapp.recipe.dto.RecipeDetailResponse.CategoryRef;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.UUID;
|
||||
|
||||
public record PantryItemResponse(
|
||||
UUID id,
|
||||
UUID ingredientId,
|
||||
String name,
|
||||
CategoryRef category,
|
||||
BigDecimal quantity,
|
||||
String unit,
|
||||
LocalDate bestBefore,
|
||||
LocalDate openedOn
|
||||
) {}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.recipeapp.pantry.dto;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
|
||||
public record UpdatePantryItemRequest(
|
||||
BigDecimal quantity,
|
||||
String unit,
|
||||
LocalDate bestBefore,
|
||||
LocalDate openedOn
|
||||
) {}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.recipeapp.pantry.entity;
|
||||
|
||||
import com.recipeapp.household.entity.Household;
|
||||
import com.recipeapp.recipe.entity.Ingredient;
|
||||
import jakarta.persistence.*;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "pantry_item")
|
||||
public class PantryItem {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
private UUID id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "household_id", nullable = false)
|
||||
private Household household;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "ingredient_id")
|
||||
private Ingredient ingredient;
|
||||
|
||||
@Column(name = "custom_name", length = 200)
|
||||
private String customName;
|
||||
|
||||
@Column(precision = 8, scale = 2)
|
||||
private BigDecimal quantity;
|
||||
|
||||
@Column(length = 20)
|
||||
private String unit;
|
||||
|
||||
@Column(name = "best_before")
|
||||
private LocalDate bestBefore;
|
||||
|
||||
@Column(name = "opened_on")
|
||||
private LocalDate openedOn;
|
||||
|
||||
protected PantryItem() {}
|
||||
|
||||
public PantryItem(Household household, Ingredient ingredient, String customName,
|
||||
BigDecimal quantity, String unit, LocalDate bestBefore, LocalDate openedOn) {
|
||||
this.household = household;
|
||||
this.ingredient = ingredient;
|
||||
this.customName = customName;
|
||||
this.quantity = quantity;
|
||||
this.unit = unit;
|
||||
this.bestBefore = bestBefore;
|
||||
this.openedOn = openedOn;
|
||||
}
|
||||
|
||||
public UUID getId() { return id; }
|
||||
public Household getHousehold() { return household; }
|
||||
public Ingredient getIngredient() { return ingredient; }
|
||||
public void setIngredient(Ingredient ingredient) { this.ingredient = ingredient; }
|
||||
public String getCustomName() { return customName; }
|
||||
public void setCustomName(String customName) { this.customName = customName; }
|
||||
public BigDecimal getQuantity() { return quantity; }
|
||||
public void setQuantity(BigDecimal quantity) { this.quantity = quantity; }
|
||||
public String getUnit() { return unit; }
|
||||
public void setUnit(String unit) { this.unit = unit; }
|
||||
public LocalDate getBestBefore() { return bestBefore; }
|
||||
public void setBestBefore(LocalDate bestBefore) { this.bestBefore = bestBefore; }
|
||||
public LocalDate getOpenedOn() { return openedOn; }
|
||||
public void setOpenedOn(LocalDate openedOn) { this.openedOn = openedOn; }
|
||||
}
|
||||
Reference in New Issue
Block a user