diff --git a/src/main/java/app/mealsmadeeasy/api/BackfillRecipeEmbeddings.java b/src/main/java/app/mealsmadeeasy/api/BackfillRecipeEmbeddings.java index 72c1027..1e999b9 100644 --- a/src/main/java/app/mealsmadeeasy/api/BackfillRecipeEmbeddings.java +++ b/src/main/java/app/mealsmadeeasy/api/BackfillRecipeEmbeddings.java @@ -1,7 +1,7 @@ package app.mealsmadeeasy.api; import app.mealsmadeeasy.api.recipe.Recipe; -import app.mealsmadeeasy.api.recipe.RecipeEmbeddingEntity; +import app.mealsmadeeasy.api.recipe.RecipeEmbedding; import app.mealsmadeeasy.api.recipe.RecipeRepository; import app.mealsmadeeasy.api.recipe.RecipeService; import org.slf4j.Logger; @@ -44,7 +44,7 @@ public class BackfillRecipeEmbeddings implements ApplicationRunner { final String toEmbed = "

" + recipe.getTitle() + "

" + renderedMarkdown; final float[] embedding = this.embeddingModel.embed(toEmbed); - final RecipeEmbeddingEntity recipeEmbedding = new RecipeEmbeddingEntity(); + final RecipeEmbedding recipeEmbedding = new RecipeEmbedding(); recipeEmbedding.setRecipe(recipe); recipeEmbedding.setEmbedding(embedding); recipeEmbedding.setTimestamp(OffsetDateTime.now()); diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/Recipe.java b/src/main/java/app/mealsmadeeasy/api/recipe/Recipe.java index 9d85a88..448c6cb 100644 --- a/src/main/java/app/mealsmadeeasy/api/recipe/Recipe.java +++ b/src/main/java/app/mealsmadeeasy/api/recipe/Recipe.java @@ -14,7 +14,7 @@ import java.util.Set; @Entity @Data -public final class Recipe { +public class Recipe { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -79,6 +79,6 @@ public final class Recipe { private Image mainImage; @OneToOne(mappedBy = "recipe", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) - private RecipeEmbeddingEntity embedding; + private RecipeEmbedding embedding; } diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeEmbedding.java b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeEmbedding.java new file mode 100644 index 0000000..8c4f5e0 --- /dev/null +++ b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeEmbedding.java @@ -0,0 +1,33 @@ +package app.mealsmadeeasy.api.recipe; + +import jakarta.persistence.*; +import lombok.Data; +import org.hibernate.annotations.Array; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; +import org.jetbrains.annotations.Nullable; + +import java.time.OffsetDateTime; + +@Entity +@Table(name = "recipe_embedding") +@Data +public class RecipeEmbedding { + + @Id + private Integer id; + + @OneToOne(fetch = FetchType.LAZY, optional = false) + @MapsId + @JoinColumn(name = "recipe_id") + private Recipe recipe; + + @JdbcTypeCode(SqlTypes.VECTOR) + @Array(length = 1024) + @Nullable + private float[] embedding; + + @Column(nullable = false) + private OffsetDateTime timestamp; + +} diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeEmbeddingEntity.java b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeEmbeddingEntity.java deleted file mode 100644 index a447df0..0000000 --- a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeEmbeddingEntity.java +++ /dev/null @@ -1,63 +0,0 @@ -package app.mealsmadeeasy.api.recipe; - -import jakarta.persistence.*; -import org.hibernate.annotations.Array; -import org.hibernate.annotations.JdbcTypeCode; -import org.hibernate.type.SqlTypes; -import org.jetbrains.annotations.Nullable; - -import java.time.OffsetDateTime; - -@Entity -@Table(name = "recipe_embedding") -public class RecipeEmbeddingEntity { - - @Id - private Integer id; - - @OneToOne(fetch = FetchType.LAZY, optional = false) - @MapsId - @JoinColumn(name = "recipe_id") - private Recipe recipe; - - @JdbcTypeCode(SqlTypes.VECTOR) - @Array(length = 1024) - @Nullable - private float[] embedding; - - @Column(nullable = false) - private OffsetDateTime timestamp; - - public Integer getId() { - return this.id; - } - - public void setId(Integer id) { - this.id = id; - } - - public Recipe getRecipe() { - return this.recipe; - } - - public void setRecipe(Recipe recipe) { - this.recipe = recipe; - } - - public float[] getEmbedding() { - return this.embedding; - } - - public void setEmbedding(float[] embedding) { - this.embedding = embedding; - } - - public OffsetDateTime getTimestamp() { - return this.timestamp; - } - - public void setTimestamp(OffsetDateTime timestamp) { - this.timestamp = timestamp; - } - -} diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeSecurity.java b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeSecurity.java index de3b273..7f28f1f 100644 --- a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeSecurity.java +++ b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeSecurity.java @@ -2,12 +2,82 @@ package app.mealsmadeeasy.api.recipe; import app.mealsmadeeasy.api.user.User; import org.jetbrains.annotations.Nullable; +import org.springframework.stereotype.Component; + +import java.util.Objects; + +@Component +public class RecipeSecurity { + + private final RecipeRepository recipeRepository; + + public RecipeSecurity(RecipeRepository recipeRepository) { + this.recipeRepository = recipeRepository; + } + + public boolean isOwner(Recipe recipe, User user) { + return recipe.getOwner() != null && recipe.getOwner().getId().equals(user.getId()); + } + + public boolean isOwner(Integer recipeId, User user) throws RecipeException { + final Recipe recipe = this.recipeRepository.findById(recipeId).orElseThrow(() -> new RecipeException( + RecipeException.Type.INVALID_ID, + "No such Recipe with id " + recipeId + )); + return this.isOwner(recipe, user); + } + + public boolean isOwner(String username, String slug, @Nullable User user) throws RecipeException { + final Recipe recipe = this.recipeRepository.findByOwnerUsernameAndSlug(username, slug).orElseThrow( + () -> new RecipeException( + RecipeException.Type.INVALID_USERNAME_OR_SLUG, + "No such Recipe for username " + username + " and slug " + slug + ) + ); + return this.isOwner(recipe, user); + } + + public boolean isViewableBy(Recipe recipe, @Nullable User user) throws RecipeException { + if (recipe.getIsPublic()) { + // public recipe + return true; + } else if (user == null) { + // a non-public recipe with no principal + return false; + } else if (Objects.equals(recipe.getOwner().getId(), user.getId())) { + // is owner + return true; + } else { + // check if viewer + final Recipe withViewers = this.recipeRepository.findByIdWithViewers(recipe.getId()) + .orElseThrow(() -> new RecipeException( + RecipeException.Type.INVALID_ID, "No such Recipe with id: " + recipe.getId() + )); + for (final User viewer : withViewers.getViewers()) { + if (viewer.getId() != null && viewer.getId().equals(user.getId())) { + return true; + } + } + } + // non-public recipe and not viewer + return false; + } + + public boolean isViewableBy(String ownerUsername, String slug, @Nullable User user) throws RecipeException { + final Recipe recipe = this.recipeRepository.findByOwnerUsernameAndSlug(ownerUsername, slug) + .orElseThrow(() -> new RecipeException( + RecipeException.Type.INVALID_USERNAME_OR_SLUG, + "No such Recipe for username " + ownerUsername + " and slug: " + slug + )); + return this.isViewableBy(recipe, user); + } + + public boolean isViewableBy(Integer recipeId, @Nullable User user) throws RecipeException { + final Recipe recipe = this.recipeRepository.findById(recipeId).orElseThrow(() -> new RecipeException( + RecipeException.Type.INVALID_ID, + "No such Recipe with id: " + recipeId + )); + return this.isViewableBy(recipe, user); + } -public interface RecipeSecurity { - boolean isOwner(Recipe recipe, User user); - boolean isOwner(Integer recipeId, User user) throws RecipeException; - boolean isOwner(String username, String slug, @Nullable User user) throws RecipeException; - boolean isViewableBy(Recipe recipe, @Nullable User user) throws RecipeException; - boolean isViewableBy(String ownerUsername, String slug, @Nullable User user) throws RecipeException; - boolean isViewableBy(Integer recipeId, @Nullable User user) throws RecipeException; } diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeSecurityImpl.java b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeSecurityImpl.java deleted file mode 100644 index d3ddffc..0000000 --- a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeSecurityImpl.java +++ /dev/null @@ -1,89 +0,0 @@ -package app.mealsmadeeasy.api.recipe; - -import app.mealsmadeeasy.api.user.User; -import org.jetbrains.annotations.Nullable; -import org.springframework.stereotype.Component; - -import java.util.Objects; - -@Component("recipeSecurity") -public class RecipeSecurityImpl implements RecipeSecurity { - - private final RecipeRepository recipeRepository; - - public RecipeSecurityImpl(RecipeRepository recipeRepository) { - this.recipeRepository = recipeRepository; - } - - @Override - public boolean isOwner(Recipe recipe, User user) { - return recipe.getOwner() != null && recipe.getOwner().getId().equals(user.getId()); - } - - @Override - public boolean isOwner(Integer recipeId, User user) throws RecipeException { - final Recipe recipe = this.recipeRepository.findById(recipeId).orElseThrow(() -> new RecipeException( - RecipeException.Type.INVALID_ID, - "No such Recipe with id " + recipeId - )); - return this.isOwner(recipe, user); - } - - @Override - public boolean isOwner(String username, String slug, @Nullable User user) throws RecipeException { - final Recipe recipe = this.recipeRepository.findByOwnerUsernameAndSlug(username, slug).orElseThrow( - () -> new RecipeException( - RecipeException.Type.INVALID_USERNAME_OR_SLUG, - "No such Recipe for username " + username + " and slug " + slug - ) - ); - return this.isOwner(recipe, user); - } - - @Override - public boolean isViewableBy(Recipe recipe, @Nullable User user) throws RecipeException { - if (recipe.getIsPublic()) { - // public recipe - return true; - } else if (user == null) { - // a non-public recipe with no principal - return false; - } else if (Objects.equals(recipe.getOwner().getId(), user.getId())) { - // is owner - return true; - } else { - // check if viewer - final Recipe withViewers = this.recipeRepository.findByIdWithViewers(recipe.getId()) - .orElseThrow(() -> new RecipeException( - RecipeException.Type.INVALID_ID, "No such Recipe with id: " + recipe.getId() - )); - for (final User viewer : withViewers.getViewers()) { - if (viewer.getId() != null && viewer.getId().equals(user.getId())) { - return true; - } - } - } - // non-public recipe and not viewer - return false; - } - - @Override - public boolean isViewableBy(String ownerUsername, String slug, @Nullable User user) throws RecipeException { - final Recipe recipe = this.recipeRepository.findByOwnerUsernameAndSlug(ownerUsername, slug) - .orElseThrow(() -> new RecipeException( - RecipeException.Type.INVALID_USERNAME_OR_SLUG, - "No such Recipe for username " + ownerUsername + " and slug: " + slug - )); - return this.isViewableBy(recipe, user); - } - - @Override - public boolean isViewableBy(Integer recipeId, @Nullable User user) throws RecipeException { - final Recipe recipe = this.recipeRepository.findById(recipeId).orElseThrow(() -> new RecipeException( - RecipeException.Type.INVALID_ID, - "No such Recipe with id: " + recipeId - )); - return this.isViewableBy(recipe, user); - } - -} diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeService.java b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeService.java index fd4cfce..b8dd48e 100644 --- a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeService.java +++ b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeService.java @@ -1,63 +1,317 @@ package app.mealsmadeeasy.api.recipe; +import app.mealsmadeeasy.api.image.Image; import app.mealsmadeeasy.api.image.ImageException; +import app.mealsmadeeasy.api.image.ImageService; +import app.mealsmadeeasy.api.image.view.ImageView; +import app.mealsmadeeasy.api.markdown.MarkdownService; import app.mealsmadeeasy.api.recipe.body.RecipeAiSearchBody; import app.mealsmadeeasy.api.recipe.spec.RecipeCreateSpec; 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 org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.Contract; import org.jetbrains.annotations.Nullable; +import org.springframework.ai.embedding.EmbeddingModel; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.access.prepost.PostAuthorize; +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; -public interface RecipeService { +@Service +public class RecipeService { - Recipe create(@Nullable User owner, RecipeCreateSpec spec); + private final RecipeRepository recipeRepository; + private final RecipeStarRepository recipeStarRepository; + private final ImageService imageService; + private final MarkdownService markdownService; + private final EmbeddingModel embeddingModel; - Recipe getById(Integer id, @Nullable User viewer) throws RecipeException; - Recipe getByIdWithStars(Integer id, @Nullable User viewer) throws RecipeException; - Recipe getByUsernameAndSlug(String username, String slug, @Nullable User viewer) throws RecipeException; + public RecipeService( + RecipeRepository recipeRepository, + RecipeStarRepository recipeStarRepository, + ImageService imageService, + MarkdownService markdownService, + EmbeddingModel embeddingModel + ) { + this.recipeRepository = recipeRepository; + this.recipeStarRepository = recipeStarRepository; + this.imageService = imageService; + this.markdownService = markdownService; + this.embeddingModel = embeddingModel; + } - FullRecipeView getFullViewById(Integer id, @Nullable User viewer) throws RecipeException; - FullRecipeView getFullViewByUsernameAndSlug( + public Recipe create(@Nullable User owner, RecipeCreateSpec spec) { + if (owner == null) { + throw new AccessDeniedException("Must be logged in."); + } + final Recipe draft = new Recipe(); + draft.setCreated(OffsetDateTime.now()); + draft.setOwner(owner); + draft.setSlug(spec.getSlug()); + draft.setTitle(spec.getTitle()); + draft.setRawText(spec.getRawText()); + draft.setMainImage(spec.getMainImage()); + draft.setIsPublic(spec.isPublic()); + return this.recipeRepository.save(draft); + } + + private Recipe findRecipeEntity(Integer id) throws RecipeException { + return this.recipeRepository.findById(id).orElseThrow(() -> new RecipeException( + RecipeException.Type.INVALID_ID, "No such Recipe with id: " + id + )); + } + + @PostAuthorize("@recipeSecurity.isViewableBy(returnObject, #viewer)") + public Recipe getById(Integer id, @Nullable User viewer) throws RecipeException { + return this.findRecipeEntity(id); + } + + @PostAuthorize("@recipeSecurity.isViewableBy(returnObject, #viewer)") + public Recipe getByIdWithStars(Integer id, @Nullable User viewer) throws RecipeException { + return this.recipeRepository.findByIdWithStars(id).orElseThrow(() -> new RecipeException( + RecipeException.Type.INVALID_ID, + "No such Recipe with id: " + id + )); + } + + @PostAuthorize("@recipeSecurity.isViewableBy(returnObject, #viewer)") + public Recipe getByUsernameAndSlug(String username, String slug, @Nullable User viewer) throws RecipeException { + return this.recipeRepository.findByOwnerUsernameAndSlug(username, slug).orElseThrow(() -> new RecipeException( + RecipeException.Type.INVALID_USERNAME_OR_SLUG, + "No such Recipe for username " + username + " and slug " + slug + )); + } + + @ApiStatus.Internal + public String getRenderedMarkdown(Recipe entity) { + if (entity.getCachedRenderedText() == null) { + entity.setCachedRenderedText(this.markdownService.renderAndCleanMarkdown(entity.getRawText())); + entity = this.recipeRepository.save(entity); + } + return entity.getCachedRenderedText(); + } + + private int getStarCount(Recipe recipe) { + return this.recipeRepository.getStarCount(recipe.getId()); + } + + private int getViewerCount(long recipeId) { + return this.recipeRepository.getViewerCount(recipeId); + } + + @Contract("null, _ -> null") + private @Nullable ImageView getImageView(@Nullable Image image, @Nullable User viewer) { + if (image != null) { + return this.imageService.toImageView(image, viewer); + } else { + return null; + } + } + + private FullRecipeView getFullView(Recipe recipe, boolean includeRawText, @Nullable User viewer) { + return FullRecipeView.from( + recipe, + this.getRenderedMarkdown(recipe), + includeRawText, + this.getStarCount(recipe), + this.getViewerCount(recipe.getId()), + this.getImageView(recipe.getMainImage(), viewer) + ); + } + + private RecipeInfoView getInfoView(Recipe recipe, @Nullable User viewer) { + return RecipeInfoView.from( + recipe, + this.getStarCount(recipe), + this.getImageView(recipe.getMainImage(), viewer) + ); + } + + @PreAuthorize("@recipeSecurity.isViewableBy(#id, #viewer)") + public FullRecipeView getFullViewById(Integer id, @Nullable User viewer) throws RecipeException { + final Recipe recipe = this.recipeRepository.findById(id).orElseThrow(() -> new RecipeException( + RecipeException.Type.INVALID_ID, "No such Recipe for id: " + id + )); + return this.getFullView(recipe, false, viewer); + } + + @PreAuthorize("@recipeSecurity.isViewableBy(#username, #slug, #viewer)") + public FullRecipeView getFullViewByUsernameAndSlug( String username, String slug, boolean includeRawText, @Nullable User viewer - ) throws RecipeException; + ) throws RecipeException { + final Recipe recipe = this.recipeRepository.findByOwnerUsernameAndSlug(username, slug) + .orElseThrow(() -> new RecipeException( + RecipeException.Type.INVALID_USERNAME_OR_SLUG, + "No such Recipe for username " + username + " and slug: " + slug + )); + return this.getFullView(recipe, includeRawText, viewer); + } - Slice getInfoViewsViewableBy(Pageable pageable, @Nullable User viewer); - List getByMinimumStars(long minimumStars, @Nullable User viewer); - List getPublicRecipes(); - List getRecipesViewableBy(User viewer); - List getRecipesOwnedBy(User owner); + public Slice getInfoViewsViewableBy(Pageable pageable, @Nullable User viewer) { + return this.recipeRepository.findAllViewableBy(viewer, pageable).map(recipe -> + this.getInfoView(recipe, viewer) + ); + } - List aiSearch(RecipeAiSearchBody searchSpec, @Nullable User viewer); + public List getByMinimumStars(long minimumStars, @Nullable User viewer) { + return List.copyOf( + this.recipeRepository.findAllViewableByStarsGreaterThanEqual(minimumStars, viewer) + ); + } - Recipe update(String username, String slug, RecipeUpdateSpec spec, User modifier) - throws RecipeException, ImageException; + public List getPublicRecipes() { + return List.copyOf(this.recipeRepository.findAllByIsPublicIsTrue()); + } - Recipe addViewer(Integer id, User modifier, User viewer) throws RecipeException; - Recipe removeViewer(Integer id, User modifier, User viewer) throws RecipeException; - Recipe clearAllViewers(Integer id, User modifier) throws RecipeException; + public List getRecipesViewableBy(User viewer) { + return List.copyOf(this.recipeRepository.findAllByViewersContaining(viewer)); + } - void deleteRecipe(Integer id, User modifier); + public List getRecipesOwnedBy(User owner) { + return List.copyOf(this.recipeRepository.findAllByOwner(owner)); + } - FullRecipeView toFullRecipeView(Recipe recipe, boolean includeRawText, @Nullable User viewer); - RecipeInfoView toRecipeInfoView(Recipe recipe, @Nullable User viewer); + public List aiSearch(RecipeAiSearchBody searchSpec, @Nullable User viewer) { + final float[] queryEmbedding = this.embeddingModel.embed(searchSpec.getPrompt()); + final List results; + if (viewer == null) { + results = this.recipeRepository.searchByEmbeddingAndIsPublic(queryEmbedding, 0.5f); + } else { + results = this.recipeRepository.searchByEmbeddingAndViewableBy(queryEmbedding, 0.5f, viewer.getId()); + } + return results.stream() + .map(recipeEntity -> this.getInfoView(recipeEntity, viewer)) + .toList(); + } + private void prepareForUpdate(RecipeUpdateSpec spec, Recipe recipe, User modifier) throws ImageException { + boolean didUpdate = false; + if (spec.getTitle() != null) { + recipe.setTitle(spec.getTitle()); + didUpdate = true; + } + if (spec.getPreparationTime() != null) { + recipe.setPreparationTime(spec.getPreparationTime()); + didUpdate = true; + } + if (spec.getCookingTime() != null) { + recipe.setCookingTime(spec.getCookingTime()); + didUpdate = true; + } + if (spec.getTotalTime() != null) { + recipe.setTotalTime(spec.getTotalTime()); + didUpdate = true; + } + + if (spec.getRawText() != null) { + recipe.setRawText(spec.getRawText()); + recipe.setCachedRenderedText(null); + didUpdate = true; + } + + if (spec.getIsPublic() != null) { + recipe.setIsPublic(spec.getIsPublic()); + didUpdate = true; + } + + // TODO: we have to think about how to unset the main image vs. just leaving it out of the request + if (spec.getMainImage() != null) { + final Image mainImage = this.imageService.getByUsernameAndFilename( + spec.getMainImage().getUsername(), + spec.getMainImage().getFilename(), + modifier + ); + recipe.setMainImage(mainImage); + } + + if (didUpdate) { + recipe.setModified(OffsetDateTime.now()); + } + } + + @PreAuthorize("@recipeSecurity.isOwner(#username, #slug, #modifier)") + public Recipe update(String username, String slug, RecipeUpdateSpec spec, User modifier) + throws RecipeException, ImageException { + final Recipe recipe = this.recipeRepository.findByOwnerUsernameAndSlug(username, slug).orElseThrow(() -> + new RecipeException( + RecipeException.Type.INVALID_USERNAME_OR_SLUG, + "No such Recipe for username " + username + " and slug: " + slug + ) + ); + this.prepareForUpdate(spec, recipe, modifier); + return this.recipeRepository.save(recipe); + } + + @PreAuthorize("@recipeSecurity.isOwner(#id, #modifier)") + public Recipe addViewer(Integer id, User modifier, User viewer) throws RecipeException { + final Recipe entity = this.recipeRepository.findByIdWithViewers(id).orElseThrow(() -> new RecipeException( + RecipeException.Type.INVALID_ID, "No such Recipe with id: " + id + )); + final Set viewers = new HashSet<>(entity.getViewers()); + viewers.add(viewer); + entity.setViewers(viewers); + return this.recipeRepository.save(entity); + } + + @PreAuthorize("@recipeSecurity.isOwner(#id, #modifier)") + public Recipe removeViewer(Integer id, User modifier, User viewer) throws RecipeException { + final Recipe entity = this.findRecipeEntity(id); + final Set viewers = new HashSet<>(entity.getViewers()); + viewers.remove(viewer); + entity.setViewers(viewers); + return this.recipeRepository.save(entity); + } + + @PreAuthorize("@recipeSecurity.isOwner(#id, #modifier)") + public Recipe clearAllViewers(Integer id, User modifier) throws RecipeException { + final Recipe entity = this.findRecipeEntity(id); + entity.setViewers(new HashSet<>()); + return this.recipeRepository.save(entity); + } + + @PreAuthorize("@recipeSecurity.isOwner(#id, #modifier)") + public void deleteRecipe(Integer id, User modifier) { + this.recipeRepository.deleteById(id); + } + + public FullRecipeView toFullRecipeView(Recipe recipe, boolean includeRawText, @Nullable User viewer) { + return this.getFullView(recipe, includeRawText, viewer); + } + + public RecipeInfoView toRecipeInfoView(Recipe recipe, @Nullable User viewer) { + return this.getInfoView(recipe, viewer); + } + + @PreAuthorize("@recipeSecurity.isViewableBy(#username, #slug, #viewer)") @Contract("_, _, null -> null") - @Nullable Boolean isStarer(String username, String slug, @Nullable User viewer); + public @Nullable Boolean isStarer(String username, String slug, @Nullable User viewer) { + if (viewer == null) { + return null; + } + return this.recipeStarRepository.isStarer(username, slug, viewer.getId()); + } + @PreAuthorize("@recipeSecurity.isViewableBy(#username, #slug, #viewer)") @Contract("_, _, null -> null") - @Nullable Boolean isOwner(String username, String slug, @Nullable User viewer); - - @ApiStatus.Internal - String getRenderedMarkdown(Recipe entity); + public @Nullable Boolean isOwner(String username, String slug, @Nullable User viewer) { + if (viewer == null) { + return null; + } + return viewer.getUsername().equals(username); + } } diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeServiceImpl.java b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeServiceImpl.java deleted file mode 100644 index bc355c0..0000000 --- a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeServiceImpl.java +++ /dev/null @@ -1,339 +0,0 @@ -package app.mealsmadeeasy.api.recipe; - -import app.mealsmadeeasy.api.image.Image; -import app.mealsmadeeasy.api.image.ImageException; -import app.mealsmadeeasy.api.image.ImageService; -import app.mealsmadeeasy.api.image.view.ImageView; -import app.mealsmadeeasy.api.markdown.MarkdownService; -import app.mealsmadeeasy.api.recipe.body.RecipeAiSearchBody; -import app.mealsmadeeasy.api.recipe.spec.RecipeCreateSpec; -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 org.jetbrains.annotations.ApiStatus; -import org.jetbrains.annotations.Contract; -import org.jetbrains.annotations.Nullable; -import org.springframework.ai.embedding.EmbeddingModel; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Slice; -import org.springframework.security.access.AccessDeniedException; -import org.springframework.security.access.prepost.PostAuthorize; -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; - -@Service -public class RecipeServiceImpl implements RecipeService { - - private final RecipeRepository recipeRepository; - private final RecipeStarRepository recipeStarRepository; - private final ImageService imageService; - private final MarkdownService markdownService; - private final EmbeddingModel embeddingModel; - - public RecipeServiceImpl( - RecipeRepository recipeRepository, - RecipeStarRepository recipeStarRepository, - ImageService imageService, - MarkdownService markdownService, - EmbeddingModel embeddingModel - ) { - this.recipeRepository = recipeRepository; - this.recipeStarRepository = recipeStarRepository; - this.imageService = imageService; - this.markdownService = markdownService; - this.embeddingModel = embeddingModel; - } - - @Override - public Recipe create(@Nullable User owner, RecipeCreateSpec spec) { - if (owner == null) { - throw new AccessDeniedException("Must be logged in."); - } - final Recipe draft = new Recipe(); - draft.setCreated(OffsetDateTime.now()); - draft.setOwner(owner); - draft.setSlug(spec.getSlug()); - draft.setTitle(spec.getTitle()); - draft.setRawText(spec.getRawText()); - draft.setMainImage(spec.getMainImage()); - draft.setIsPublic(spec.isPublic()); - return this.recipeRepository.save(draft); - } - - private Recipe findRecipeEntity(Integer id) throws RecipeException { - return this.recipeRepository.findById(id).orElseThrow(() -> new RecipeException( - RecipeException.Type.INVALID_ID, "No such Recipe with id: " + id - )); - } - - @Override - @PostAuthorize("@recipeSecurity.isViewableBy(returnObject, #viewer)") - public Recipe getById(Integer id, User viewer) throws RecipeException { - return this.findRecipeEntity(id); - } - - @Override - @PostAuthorize("@recipeSecurity.isViewableBy(returnObject, #viewer)") - public Recipe getByIdWithStars(Integer id, @Nullable User viewer) throws RecipeException { - return this.recipeRepository.findByIdWithStars(id).orElseThrow(() -> new RecipeException( - RecipeException.Type.INVALID_ID, - "No such Recipe with id: " + id - )); - } - - @Override - @PostAuthorize("@recipeSecurity.isViewableBy(returnObject, #viewer)") - public Recipe getByUsernameAndSlug(String username, String slug, @Nullable User viewer) throws RecipeException { - return this.recipeRepository.findByOwnerUsernameAndSlug(username, slug).orElseThrow(() -> new RecipeException( - RecipeException.Type.INVALID_USERNAME_OR_SLUG, - "No such Recipe for username " + username + " and slug " + slug - )); - } - - @Override - @ApiStatus.Internal - public String getRenderedMarkdown(Recipe entity) { - if (entity.getCachedRenderedText() == null) { - entity.setCachedRenderedText(this.markdownService.renderAndCleanMarkdown(entity.getRawText())); - entity = this.recipeRepository.save(entity); - } - return entity.getCachedRenderedText(); - } - - private int getStarCount(Recipe recipe) { - return this.recipeRepository.getStarCount(recipe.getId()); - } - - private int getViewerCount(long recipeId) { - return this.recipeRepository.getViewerCount(recipeId); - } - - @Contract("null, _ -> null") - private @Nullable ImageView getImageView(@Nullable Image image, @Nullable User viewer) { - if (image != null) { - return this.imageService.toImageView(image, viewer); - } else { - return null; - } - } - - private FullRecipeView getFullView(Recipe recipe, boolean includeRawText, @Nullable User viewer) { - return FullRecipeView.from( - recipe, - this.getRenderedMarkdown(recipe), - includeRawText, - this.getStarCount(recipe), - this.getViewerCount(recipe.getId()), - this.getImageView(recipe.getMainImage(), viewer) - ); - } - - private RecipeInfoView getInfoView(Recipe recipe, @Nullable User viewer) { - return RecipeInfoView.from( - recipe, - this.getStarCount(recipe), - this.getImageView(recipe.getMainImage(), viewer) - ); - } - - @Override - @PreAuthorize("@recipeSecurity.isViewableBy(#id, #viewer)") - public FullRecipeView getFullViewById(Integer id, @Nullable User viewer) throws RecipeException { - final Recipe recipe = this.recipeRepository.findById(id).orElseThrow(() -> new RecipeException( - RecipeException.Type.INVALID_ID, "No such Recipe for id: " + id - )); - return this.getFullView(recipe, false, viewer); - } - - @Override - @PreAuthorize("@recipeSecurity.isViewableBy(#username, #slug, #viewer)") - public FullRecipeView getFullViewByUsernameAndSlug( - String username, - String slug, - boolean includeRawText, - @Nullable User viewer - ) throws RecipeException { - final Recipe recipe = this.recipeRepository.findByOwnerUsernameAndSlug(username, slug) - .orElseThrow(() -> new RecipeException( - RecipeException.Type.INVALID_USERNAME_OR_SLUG, - "No such Recipe for username " + username + " and slug: " + slug - )); - return this.getFullView(recipe, includeRawText, viewer); - } - - @Override - public Slice getInfoViewsViewableBy(Pageable pageable, @Nullable User viewer) { - return this.recipeRepository.findAllViewableBy(viewer, pageable).map(recipe -> - this.getInfoView(recipe, viewer) - ); - } - - @Override - public List getByMinimumStars(long minimumStars, User viewer) { - return List.copyOf( - this.recipeRepository.findAllViewableByStarsGreaterThanEqual(minimumStars, viewer) - ); - } - - @Override - public List getPublicRecipes() { - return List.copyOf(this.recipeRepository.findAllByIsPublicIsTrue()); - } - - @Override - public List getRecipesViewableBy(User viewer) { - return List.copyOf(this.recipeRepository.findAllByViewersContaining(viewer)); - } - - @Override - public List getRecipesOwnedBy(User owner) { - return List.copyOf(this.recipeRepository.findAllByOwner(owner)); - } - - @Override - public List aiSearch(RecipeAiSearchBody searchSpec, @Nullable User viewer) { - final float[] queryEmbedding = this.embeddingModel.embed(searchSpec.getPrompt()); - final List results; - if (viewer == null) { - results = this.recipeRepository.searchByEmbeddingAndIsPublic(queryEmbedding, 0.5f); - } else { - results = this.recipeRepository.searchByEmbeddingAndViewableBy(queryEmbedding, 0.5f, viewer.getId()); - } - return results.stream() - .map(recipeEntity -> this.getInfoView(recipeEntity, viewer)) - .toList(); - } - - private void prepareForUpdate(RecipeUpdateSpec spec, Recipe recipe, User modifier) throws ImageException { - boolean didUpdate = false; - if (spec.getTitle() != null) { - recipe.setTitle(spec.getTitle()); - didUpdate = true; - } - if (spec.getPreparationTime() != null) { - recipe.setPreparationTime(spec.getPreparationTime()); - didUpdate = true; - } - if (spec.getCookingTime() != null) { - recipe.setCookingTime(spec.getCookingTime()); - didUpdate = true; - } - if (spec.getTotalTime() != null) { - recipe.setTotalTime(spec.getTotalTime()); - didUpdate = true; - } - - if (spec.getRawText() != null) { - recipe.setRawText(spec.getRawText()); - recipe.setCachedRenderedText(null); - didUpdate = true; - } - - if (spec.getIsPublic() != null) { - recipe.setIsPublic(spec.getIsPublic()); - didUpdate = true; - } - - // TODO: we have to think about how to unset the main image vs. just leaving it out of the request - if (spec.getMainImage() != null) { - final Image mainImage = this.imageService.getByUsernameAndFilename( - spec.getMainImage().getUsername(), - spec.getMainImage().getFilename(), - modifier - ); - recipe.setMainImage(mainImage); - } - - if (didUpdate) { - recipe.setModified(OffsetDateTime.now()); - } - } - - @Override - @PreAuthorize("@recipeSecurity.isOwner(#username, #slug, #modifier)") - public Recipe update(String username, String slug, RecipeUpdateSpec spec, User modifier) - throws RecipeException, ImageException { - final Recipe recipe = this.recipeRepository.findByOwnerUsernameAndSlug(username, slug).orElseThrow(() -> - new RecipeException( - RecipeException.Type.INVALID_USERNAME_OR_SLUG, - "No such Recipe for username " + username + " and slug: " + slug - ) - ); - this.prepareForUpdate(spec, recipe, modifier); - return this.recipeRepository.save(recipe); - } - - @Override - @PreAuthorize("@recipeSecurity.isOwner(#id, #modifier)") - public Recipe addViewer(Integer id, User modifier, User viewer) throws RecipeException { - final Recipe entity = this.recipeRepository.findByIdWithViewers(id).orElseThrow(() -> new RecipeException( - RecipeException.Type.INVALID_ID, "No such Recipe with id: " + id - )); - final Set viewers = new HashSet<>(entity.getViewers()); - viewers.add(viewer); - entity.setViewers(viewers); - return this.recipeRepository.save(entity); - } - - @Override - @PreAuthorize("@recipeSecurity.isOwner(#id, #modifier)") - public Recipe removeViewer(Integer id, User modifier, User viewer) throws RecipeException { - final Recipe entity = this.findRecipeEntity(id); - final Set viewers = new HashSet<>(entity.getViewers()); - viewers.remove(viewer); - entity.setViewers(viewers); - return this.recipeRepository.save(entity); - } - - @Override - @PreAuthorize("@recipeSecurity.isOwner(#id, #modifier)") - public Recipe clearAllViewers(Integer id, User modifier) throws RecipeException { - final Recipe entity = this.findRecipeEntity(id); - entity.setViewers(new HashSet<>()); - return this.recipeRepository.save(entity); - } - - @Override - @PreAuthorize("@recipeSecurity.isOwner(#id, #modifier)") - public void deleteRecipe(Integer id, User modifier) { - this.recipeRepository.deleteById(id); - } - - @Override - public FullRecipeView toFullRecipeView(Recipe recipe, boolean includeRawText, @Nullable User viewer) { - return this.getFullView(recipe, includeRawText, viewer); - } - - @Override - public RecipeInfoView toRecipeInfoView(Recipe recipe, @Nullable User viewer) { - return this.getInfoView(recipe, viewer); - } - - @Override - @PreAuthorize("@recipeSecurity.isViewableBy(#username, #slug, #viewer)") - @Contract("_, _, null -> null") - public @Nullable Boolean isStarer(String username, String slug, @Nullable User viewer) { - if (viewer == null) { - return null; - } - return this.recipeStarRepository.isStarer(username, slug, viewer.getId()); - } - - @Override - @PreAuthorize("@recipeSecurity.isViewableBy(#username, #slug, #viewer)") - @Contract("_, _, null -> null") - public @Nullable Boolean isOwner(String username, String slug, @Nullable User viewer) { - if (viewer == null) { - return null; - } - return viewer.getUsername().equals(username); - } - -}