Add endpoints for recipe draft manipulation.
This commit is contained in:
parent
547c04fbab
commit
2d2fa524fa
@ -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<RecipeDraft> maybeTarget = this.recipeDraftRepository.findById(id);
|
||||
if (maybeTarget.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
final RecipeDraft target = maybeTarget.get();
|
||||
return target.getOwner().getId().equals(modifier.getId());
|
||||
}
|
||||
|
||||
}
|
||||
@ -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<RecipeDraft> createRecipeDraft(
|
||||
private @Nullable ImageView getImageView(RecipeDraft recipeDraft, User viewer) {
|
||||
return recipeDraft.getMainImage() != null
|
||||
? this.imageService.toImageView(recipeDraft.getMainImage(), viewer)
|
||||
: null;
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<RecipeDraftView> getRecipeDraft(
|
||||
@PathVariable UUID id,
|
||||
@AuthenticationPrincipal User viewer
|
||||
) {
|
||||
if (viewer == null) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
|
||||
}
|
||||
final Optional<RecipeDraft> 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<RecipeDraftView> 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<RecipeDraftView> 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<RecipeDraftView> 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<Void> 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<FullRecipeView> 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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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<RecipeDraft> 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<RecipeDraft.IngredientDraft> 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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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<IngredientDraftUpdateBody> ingredients,
|
||||
@Nullable SetImageBody mainImage
|
||||
) {
|
||||
|
||||
public record IngredientDraftUpdateBody(
|
||||
@Nullable String amount,
|
||||
String name,
|
||||
@Nullable String notes
|
||||
) {}
|
||||
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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<IngredientDraftUpdateSpec> ingredients,
|
||||
@Nullable Image mainImage
|
||||
) {
|
||||
|
||||
@Builder
|
||||
public record IngredientDraftUpdateSpec(
|
||||
@Nullable String amount,
|
||||
String name,
|
||||
@Nullable String notes
|
||||
) {}
|
||||
|
||||
}
|
||||
@ -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<RecipeDraft.IngredientDraft> 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();
|
||||
}
|
||||
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
package app.mealsmadeeasy.api.util;
|
||||
|
||||
public record SetImageBody(
|
||||
String username,
|
||||
String userFilename
|
||||
) {}
|
||||
Loading…
Reference in New Issue
Block a user