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);
- }
-
-}