More pushing down interface methods for recipes.

This commit is contained in:
Jesse Brault 2026-01-16 07:52:41 -06:00
parent fc19361ab6
commit 012bf743a1
8 changed files with 395 additions and 529 deletions

View File

@ -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());

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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