More pushing down interface methods for recipes.
This commit is contained in:
parent
fc19361ab6
commit
012bf743a1
@ -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 = "<h1>" + recipe.getTitle() + "</h1>" + 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());
|
||||
|
||||
@ -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;
|
||||
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@ -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<RecipeInfoView> getInfoViewsViewableBy(Pageable pageable, @Nullable User viewer);
|
||||
List<Recipe> getByMinimumStars(long minimumStars, @Nullable User viewer);
|
||||
List<Recipe> getPublicRecipes();
|
||||
List<Recipe> getRecipesViewableBy(User viewer);
|
||||
List<Recipe> getRecipesOwnedBy(User owner);
|
||||
public Slice<RecipeInfoView> getInfoViewsViewableBy(Pageable pageable, @Nullable User viewer) {
|
||||
return this.recipeRepository.findAllViewableBy(viewer, pageable).map(recipe ->
|
||||
this.getInfoView(recipe, viewer)
|
||||
);
|
||||
}
|
||||
|
||||
List<RecipeInfoView> aiSearch(RecipeAiSearchBody searchSpec, @Nullable User viewer);
|
||||
public List<Recipe> 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<Recipe> 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<Recipe> getRecipesViewableBy(User viewer) {
|
||||
return List.copyOf(this.recipeRepository.findAllByViewersContaining(viewer));
|
||||
}
|
||||
|
||||
void deleteRecipe(Integer id, User modifier);
|
||||
public List<Recipe> 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<RecipeInfoView> aiSearch(RecipeAiSearchBody searchSpec, @Nullable User viewer) {
|
||||
final float[] queryEmbedding = this.embeddingModel.embed(searchSpec.getPrompt());
|
||||
final List<Recipe> 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<User> 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<User> 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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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<RecipeInfoView> getInfoViewsViewableBy(Pageable pageable, @Nullable User viewer) {
|
||||
return this.recipeRepository.findAllViewableBy(viewer, pageable).map(recipe ->
|
||||
this.getInfoView(recipe, viewer)
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Recipe> getByMinimumStars(long minimumStars, User viewer) {
|
||||
return List.copyOf(
|
||||
this.recipeRepository.findAllViewableByStarsGreaterThanEqual(minimumStars, viewer)
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Recipe> getPublicRecipes() {
|
||||
return List.copyOf(this.recipeRepository.findAllByIsPublicIsTrue());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Recipe> getRecipesViewableBy(User viewer) {
|
||||
return List.copyOf(this.recipeRepository.findAllByViewersContaining(viewer));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Recipe> getRecipesOwnedBy(User owner) {
|
||||
return List.copyOf(this.recipeRepository.findAllByOwner(owner));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<RecipeInfoView> aiSearch(RecipeAiSearchBody searchSpec, @Nullable User viewer) {
|
||||
final float[] queryEmbedding = this.embeddingModel.embed(searchSpec.getPrompt());
|
||||
final List<Recipe> 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<User> 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<User> 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);
|
||||
}
|
||||
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user