From 2d2fa524fa17b712fdb4d93f2c52393da65216b2 Mon Sep 17 00:00:00 2001 From: Jesse Brault Date: Mon, 19 Jan 2026 13:04:02 -0600 Subject: [PATCH] Add endpoints for recipe draft manipulation. --- .../api/recipe/RecipeDraftSecurity.java | 36 +++++++ .../api/recipe/RecipeDraftsController.java | 98 +++++++++++++++++-- .../api/recipe/RecipeService.java | 79 ++++++++++++++- .../recipe/body/RecipeDraftUpdateBody.java | 25 +++++ .../RecipeDraftUpdateBodyToSpecConverter.java | 46 +++++++++ .../recipe/spec/RecipeDraftUpdateSpec.java | 28 ++++++ .../api/recipe/view/RecipeDraftView.java | 50 ++++++++++ .../api/util/NoSuchEntityWithIdException.java | 17 ++++ .../mealsmadeeasy/api/util/SetImageBody.java | 6 ++ 9 files changed, 373 insertions(+), 12 deletions(-) create mode 100644 src/main/java/app/mealsmadeeasy/api/recipe/RecipeDraftSecurity.java create mode 100644 src/main/java/app/mealsmadeeasy/api/recipe/body/RecipeDraftUpdateBody.java create mode 100644 src/main/java/app/mealsmadeeasy/api/recipe/converter/RecipeDraftUpdateBodyToSpecConverter.java create mode 100644 src/main/java/app/mealsmadeeasy/api/recipe/spec/RecipeDraftUpdateSpec.java create mode 100644 src/main/java/app/mealsmadeeasy/api/recipe/view/RecipeDraftView.java create mode 100644 src/main/java/app/mealsmadeeasy/api/util/NoSuchEntityWithIdException.java create mode 100644 src/main/java/app/mealsmadeeasy/api/util/SetImageBody.java diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeDraftSecurity.java b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeDraftSecurity.java new file mode 100644 index 0000000..26fca21 --- /dev/null +++ b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeDraftSecurity.java @@ -0,0 +1,36 @@ +package app.mealsmadeeasy.api.recipe; + +import app.mealsmadeeasy.api.user.User; +import lombok.RequiredArgsConstructor; +import org.jetbrains.annotations.Nullable; +import org.springframework.stereotype.Component; + +import java.util.Optional; +import java.util.UUID; + +@Component +@RequiredArgsConstructor +public class RecipeDraftSecurity { + + private final RecipeDraftRepository recipeDraftRepository; + + public boolean isViewableBy(RecipeDraft recipeDraft, @Nullable User viewer) { + if (viewer == null) { + return false; + } + return recipeDraft.getOwner().getId().equals(viewer.getId()); + } + + public boolean isUpdatableBy(UUID id, @Nullable User modifier) { + if (modifier == null) { + return false; + } + final Optional maybeTarget = this.recipeDraftRepository.findById(id); + if (maybeTarget.isEmpty()) { + return false; + } + final RecipeDraft target = maybeTarget.get(); + return target.getOwner().getId().equals(modifier.getId()); + } + +} diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeDraftsController.java b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeDraftsController.java index 5d8f736..210d707 100644 --- a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeDraftsController.java +++ b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeDraftsController.java @@ -2,18 +2,26 @@ package app.mealsmadeeasy.api.recipe; import app.mealsmadeeasy.api.file.File; import app.mealsmadeeasy.api.file.FileService; +import app.mealsmadeeasy.api.image.ImageException; +import app.mealsmadeeasy.api.image.ImageService; +import app.mealsmadeeasy.api.image.view.ImageView; +import app.mealsmadeeasy.api.recipe.body.RecipeDraftUpdateBody; +import app.mealsmadeeasy.api.recipe.converter.RecipeDraftUpdateBodyToSpecConverter; +import app.mealsmadeeasy.api.recipe.spec.RecipeDraftUpdateSpec; +import app.mealsmadeeasy.api.recipe.view.FullRecipeView; +import app.mealsmadeeasy.api.recipe.view.RecipeDraftView; import app.mealsmadeeasy.api.user.User; import lombok.RequiredArgsConstructor; +import org.jetbrains.annotations.Nullable; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; +import java.util.Optional; +import java.util.UUID; @RestController @RequestMapping("/recipe-drafts") @@ -22,15 +30,51 @@ public class RecipeDraftsController { private final RecipeService recipeService; private final FileService fileService; + private final ImageService imageService; + private final RecipeDraftUpdateBodyToSpecConverter updateBodyToSpecConverter; - @PutMapping("/ai") - public ResponseEntity createRecipeDraft( + private @Nullable ImageView getImageView(RecipeDraft recipeDraft, User viewer) { + return recipeDraft.getMainImage() != null + ? this.imageService.toImageView(recipeDraft.getMainImage(), viewer) + : null; + } + + @GetMapping("/{id}") + public ResponseEntity getRecipeDraft( + @PathVariable UUID id, + @AuthenticationPrincipal User viewer + ) { + if (viewer == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + final Optional maybeRecipeDraft = this.recipeService.findDraftById(id, viewer); + if (maybeRecipeDraft.isEmpty()) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).build(); + } else { + final RecipeDraft recipeDraft = maybeRecipeDraft.get(); + final @Nullable ImageView imageView = this.getImageView(recipeDraft, viewer); + return ResponseEntity.ok(RecipeDraftView.from(recipeDraft, imageView)); + } + } + + @PostMapping("/manual") + public ResponseEntity createManualDraft(@AuthenticationPrincipal User owner) { + if (owner == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + final RecipeDraft recipeDraft = this.recipeService.createDraft(owner); + final ImageView imageView = this.getImageView(recipeDraft, owner); + return ResponseEntity.ok(RecipeDraftView.from(recipeDraft, imageView)); + } + + @PostMapping("/ai") + public ResponseEntity createAiDraft( @AuthenticationPrincipal User owner, @RequestParam MultipartFile sourceFile, @RequestParam String sourceFileName ) throws IOException { if (owner == null) { - throw new IllegalArgumentException("Must be logged in to create a RecipeDraft."); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } final File file = this.fileService.create( sourceFile.getInputStream(), @@ -39,7 +83,45 @@ public class RecipeDraftsController { owner ); final RecipeDraft recipeDraft = this.recipeService.createAiDraft(file, owner); - return ResponseEntity.status(HttpStatus.CREATED).body(recipeDraft); + final @Nullable ImageView mainImageView = this.getImageView(recipeDraft, owner); + return ResponseEntity.status(HttpStatus.CREATED).body(RecipeDraftView.from(recipeDraft, mainImageView)); + } + + @PutMapping("/{id}") + public ResponseEntity updateRecipeDraft( + @AuthenticationPrincipal User modifier, + @PathVariable UUID id, + @RequestBody RecipeDraftUpdateBody updateBody + ) throws ImageException { + if (modifier == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + final RecipeDraftUpdateSpec spec = this.updateBodyToSpecConverter.convert(updateBody, modifier); + final RecipeDraft updated = this.recipeService.updateDraft(id, spec, modifier); + final @Nullable ImageView imageView = this.getImageView(updated, modifier); + return ResponseEntity.ok(RecipeDraftView.from(updated, imageView)); + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteRecipeDraft( + @AuthenticationPrincipal User modifier, + @PathVariable UUID id + ) { + if (modifier == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + this.recipeService.deleteDraft(id, modifier); + return ResponseEntity.noContent().build(); + } + + @PostMapping("/{id}") + public ResponseEntity publishRecipeDraft( + @AuthenticationPrincipal User modifier, + @PathVariable UUID id + ) { + final Recipe recipe = this.recipeService.publishDraft(id, modifier); + final FullRecipeView view = this.recipeService.toFullRecipeView(recipe, false, modifier); + return ResponseEntity.status(HttpStatus.CREATED).body(view); } } diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeService.java b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeService.java index 5b8a63c..65b4a9b 100644 --- a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeService.java +++ b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeService.java @@ -11,11 +11,13 @@ import app.mealsmadeeasy.api.recipe.body.RecipeAiSearchBody; import app.mealsmadeeasy.api.recipe.job.RecipeInferJobHandler; import app.mealsmadeeasy.api.recipe.job.RecipeInferJobPayload; import app.mealsmadeeasy.api.recipe.spec.RecipeCreateSpec; +import app.mealsmadeeasy.api.recipe.spec.RecipeDraftUpdateSpec; import app.mealsmadeeasy.api.recipe.spec.RecipeUpdateSpec; import app.mealsmadeeasy.api.recipe.star.RecipeStarRepository; import app.mealsmadeeasy.api.recipe.view.FullRecipeView; import app.mealsmadeeasy.api.recipe.view.RecipeInfoView; import app.mealsmadeeasy.api.user.User; +import app.mealsmadeeasy.api.util.NoSuchEntityWithIdException; import jakarta.transaction.Transactional; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.Contract; @@ -29,10 +31,7 @@ import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.stereotype.Service; import java.time.OffsetDateTime; -import java.util.HashSet; -import java.util.List; -import java.util.Set; -import java.util.UUID; +import java.util.*; @Service public class RecipeService { @@ -359,8 +358,80 @@ public class RecipeService { )); } + @PostAuthorize("@recipeDraftSecurity.isViewableBy(#returnObject, #viewer)") + public Optional findDraftById(UUID id, @Nullable User viewer) { + return this.recipeDraftRepository.findById(id); + } + public RecipeDraft saveDraft(RecipeDraft recipeDraft) { return this.recipeDraftRepository.save(recipeDraft); } + @PreAuthorize("@recipeDraftSecurity.isUpdatableBy(#id, #modifier)") + public RecipeDraft updateDraft(UUID id, RecipeDraftUpdateSpec spec, User modifier) { + final RecipeDraft recipeDraft = this.recipeDraftRepository.findById(id) + .orElseThrow(() -> new NoSuchEntityWithIdException(RecipeDraft.class, id)); + if (spec.slug() != null) { + recipeDraft.setSlug(spec.slug()); + } + if (spec.title() != null) { + recipeDraft.setTitle(spec.title()); + } + if (spec.preparationTime() != null) { + recipeDraft.setPreparationTime(spec.preparationTime()); + } + if (spec.cookingTime() != null) { + recipeDraft.setCookingTime(spec.cookingTime()); + } + if (spec.totalTime() != null) { + recipeDraft.setTotalTime(spec.totalTime()); + } + if (spec.rawText() != null) { + recipeDraft.setRawText(spec.rawText()); + } + if (spec.ingredients() != null) { + final List ingredients = spec.ingredients() + .stream() + .map(ingredientSpec -> { + final var ingredient = new RecipeDraft.IngredientDraft(); + ingredient.setAmount(ingredientSpec.amount()); + ingredient.setName(ingredientSpec.name()); + ingredient.setNotes(ingredientSpec.notes()); + return ingredient; + }) + .toList(); + recipeDraft.setIngredients(ingredients); + } + if (spec.mainImage() != null) { + recipeDraft.setMainImage(spec.mainImage()); + } + recipeDraft.setModified(OffsetDateTime.now()); + return this.recipeDraftRepository.save(recipeDraft); + } + + @PreAuthorize("@recipeDraftSecurity.isUpdatableBy(#id, #modifier)") + public void deleteDraft(UUID id, User modifier) { + this.recipeDraftRepository.deleteById(id); + } + + @PreAuthorize("@recipeDraftSecurity.isUpdatableBy(#draftId, #modifier)") + @Transactional + public Recipe publishDraft(UUID draftId, User modifier) { + final RecipeDraft recipeDraft = this.recipeDraftRepository.findById(draftId) + .orElseThrow(() -> new NoSuchEntityWithIdException(RecipeDraft.class, draftId)); + final RecipeCreateSpec spec = RecipeCreateSpec.builder() + .slug(recipeDraft.getSlug()) + .title(recipeDraft.getTitle()) + .preparationTime(recipeDraft.getPreparationTime()) + .cookingTime(recipeDraft.getCookingTime()) + .totalTime(recipeDraft.getTotalTime()) + .rawText(recipeDraft.getRawText()) + .isPublic(false) + .mainImage(recipeDraft.getMainImage()) + .build(); + final Recipe recipe = this.create(recipeDraft.getOwner(), spec); + this.recipeDraftRepository.deleteById(draftId); // delete old draft + return recipe; + } + } diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/body/RecipeDraftUpdateBody.java b/src/main/java/app/mealsmadeeasy/api/recipe/body/RecipeDraftUpdateBody.java new file mode 100644 index 0000000..36af88c --- /dev/null +++ b/src/main/java/app/mealsmadeeasy/api/recipe/body/RecipeDraftUpdateBody.java @@ -0,0 +1,25 @@ +package app.mealsmadeeasy.api.recipe.body; + +import app.mealsmadeeasy.api.util.SetImageBody; +import org.jetbrains.annotations.Nullable; + +import java.util.List; + +public record RecipeDraftUpdateBody( + @Nullable String slug, + @Nullable String title, + @Nullable Integer preparationTime, + @Nullable Integer cookingTime, + @Nullable Integer totalTime, + @Nullable String rawText, + @Nullable List ingredients, + @Nullable SetImageBody mainImage +) { + + public record IngredientDraftUpdateBody( + @Nullable String amount, + String name, + @Nullable String notes + ) {} + +} diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/converter/RecipeDraftUpdateBodyToSpecConverter.java b/src/main/java/app/mealsmadeeasy/api/recipe/converter/RecipeDraftUpdateBodyToSpecConverter.java new file mode 100644 index 0000000..bc6d93f --- /dev/null +++ b/src/main/java/app/mealsmadeeasy/api/recipe/converter/RecipeDraftUpdateBodyToSpecConverter.java @@ -0,0 +1,46 @@ +package app.mealsmadeeasy.api.recipe.converter; + +import app.mealsmadeeasy.api.image.Image; +import app.mealsmadeeasy.api.image.ImageException; +import app.mealsmadeeasy.api.image.ImageService; +import app.mealsmadeeasy.api.recipe.body.RecipeDraftUpdateBody; +import app.mealsmadeeasy.api.recipe.spec.RecipeDraftUpdateSpec; +import app.mealsmadeeasy.api.user.User; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class RecipeDraftUpdateBodyToSpecConverter { + + private final ImageService imageService; + + public RecipeDraftUpdateSpec convert(RecipeDraftUpdateBody body, User viewer) throws ImageException { + final var b = RecipeDraftUpdateSpec.builder() + .slug(body.slug()) + .title(body.title()) + .preparationTime(body.preparationTime()) + .cookingTime(body.cookingTime()) + .totalTime(body.totalTime()) + .rawText(body.rawText()); + if (body.ingredients() != null) { + final var ingredients = body.ingredients().stream() + .map(ingredientBody -> RecipeDraftUpdateSpec.IngredientDraftUpdateSpec.builder() + .amount(ingredientBody.amount()) + .name(ingredientBody.name()) + .notes(ingredientBody.notes()) + .build() + ).toList(); + b.ingredients(ingredients); + } + if (body.mainImage() != null) { + final Image mainImage = this.imageService.getByUsernameAndFilename( + body.mainImage().username(), + body.mainImage().userFilename(), + viewer + ); + b.mainImage(mainImage); + } + return b.build(); + } +} diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/spec/RecipeDraftUpdateSpec.java b/src/main/java/app/mealsmadeeasy/api/recipe/spec/RecipeDraftUpdateSpec.java new file mode 100644 index 0000000..ba4488f --- /dev/null +++ b/src/main/java/app/mealsmadeeasy/api/recipe/spec/RecipeDraftUpdateSpec.java @@ -0,0 +1,28 @@ +package app.mealsmadeeasy.api.recipe.spec; + +import app.mealsmadeeasy.api.image.Image; +import lombok.Builder; +import org.jetbrains.annotations.Nullable; + +import java.util.List; + +@Builder +public record RecipeDraftUpdateSpec( + @Nullable String slug, + @Nullable String title, + @Nullable Integer preparationTime, + @Nullable Integer cookingTime, + @Nullable Integer totalTime, + @Nullable String rawText, + @Nullable List ingredients, + @Nullable Image mainImage +) { + + @Builder + public record IngredientDraftUpdateSpec( + @Nullable String amount, + String name, + @Nullable String notes + ) {} + +} diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/view/RecipeDraftView.java b/src/main/java/app/mealsmadeeasy/api/recipe/view/RecipeDraftView.java new file mode 100644 index 0000000..5a23bbb --- /dev/null +++ b/src/main/java/app/mealsmadeeasy/api/recipe/view/RecipeDraftView.java @@ -0,0 +1,50 @@ +package app.mealsmadeeasy.api.recipe.view; + +import app.mealsmadeeasy.api.image.view.ImageView; +import app.mealsmadeeasy.api.recipe.RecipeDraft; +import app.mealsmadeeasy.api.user.view.UserInfoView; +import lombok.Builder; +import org.jetbrains.annotations.Nullable; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.UUID; + +@Builder +public record RecipeDraftView( + UUID id, + OffsetDateTime created, + @Nullable OffsetDateTime modified, + RecipeDraft.State state, + @Nullable String slug, + @Nullable String title, + @Nullable Integer preparationTime, + @Nullable Integer cookingTime, + @Nullable Integer totalTime, + @Nullable String rawText, + @Nullable List ingredients, + UserInfoView owner, + @Nullable ImageView mainImage +) { + + public static RecipeDraftView from( + RecipeDraft recipeDraft, + @Nullable ImageView mainImageView + ) { + return RecipeDraftView.builder() + .id(recipeDraft.getId()) + .created(recipeDraft.getCreated()) + .modified(recipeDraft.getModified()) + .state(recipeDraft.getState()) + .slug(recipeDraft.getSlug()) + .preparationTime(recipeDraft.getPreparationTime()) + .cookingTime(recipeDraft.getCookingTime()) + .totalTime(recipeDraft.getTotalTime()) + .rawText(recipeDraft.getRawText()) + .ingredients(recipeDraft.getIngredients()) + .owner(UserInfoView.from(recipeDraft.getOwner())) + .mainImage(mainImageView) + .build(); + } + +} diff --git a/src/main/java/app/mealsmadeeasy/api/util/NoSuchEntityWithIdException.java b/src/main/java/app/mealsmadeeasy/api/util/NoSuchEntityWithIdException.java new file mode 100644 index 0000000..2b78874 --- /dev/null +++ b/src/main/java/app/mealsmadeeasy/api/util/NoSuchEntityWithIdException.java @@ -0,0 +1,17 @@ +package app.mealsmadeeasy.api.util; + +import lombok.Getter; + +@Getter +public class NoSuchEntityWithIdException extends RuntimeException { + + private final Class entityType; + private final Object id; + + public NoSuchEntityWithIdException(Class entityType, Object id) { + super("Could not find entity " + entityType.getSimpleName() + " with id " + id); + this.entityType = entityType; + this.id = id; + } + +} diff --git a/src/main/java/app/mealsmadeeasy/api/util/SetImageBody.java b/src/main/java/app/mealsmadeeasy/api/util/SetImageBody.java new file mode 100644 index 0000000..13f3087 --- /dev/null +++ b/src/main/java/app/mealsmadeeasy/api/util/SetImageBody.java @@ -0,0 +1,6 @@ +package app.mealsmadeeasy.api.util; + +public record SetImageBody( + String username, + String userFilename +) {}