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.S3ImageEntity; import app.mealsmadeeasy.api.image.view.ImageView; import app.mealsmadeeasy.api.markdown.MarkdownService; import app.mealsmadeeasy.api.recipe.spec.RecipeAiSearchSpec; 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 app.mealsmadeeasy.api.user.UserEntity; 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((UserEntity) owner); draft.setSlug(spec.getSlug()); draft.setTitle(spec.getTitle()); draft.setRawText(spec.getRawText()); draft.setMainImage((S3ImageEntity) spec.getMainImage()); draft.setIsPublic(spec.isPublic()); return this.recipeRepository.save(draft); } private Recipe findRecipeEntity(long 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(long id, User viewer) throws RecipeException { return this.findRecipeEntity(id); } @Override @PostAuthorize("@recipeSecurity.isViewableBy(returnObject, #viewer)") public Recipe getByIdWithStars(long 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(long 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((UserEntity) viewer, pageable).map(recipe -> this.getInfoView(recipe, viewer) ); } @Override public List getByMinimumStars(long minimumStars, User viewer) { return List.copyOf( this.recipeRepository.findAllViewableByStarsGreaterThanEqual(minimumStars, (UserEntity) viewer) ); } @Override public List getPublicRecipes() { return List.copyOf(this.recipeRepository.findAllByIsPublicIsTrue()); } @Override public List getRecipesViewableBy(User viewer) { return List.copyOf(this.recipeRepository.findAllByViewersContaining((UserEntity) viewer)); } @Override public List getRecipesOwnedBy(User owner) { return List.copyOf(this.recipeRepository.findAllByOwner((UserEntity) owner)); } @Override public List aiSearch(RecipeAiSearchSpec 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(); } @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 ) ); recipe.setTitle(spec.getTitle()); recipe.setPreparationTime(spec.getPreparationTime()); recipe.setCookingTime(spec.getCookingTime()); recipe.setTotalTime(spec.getTotalTime()); recipe.setRawText(spec.getRawText()); recipe.setCachedRenderedText(null); recipe.setIsPublic(spec.getIsPublic()); final S3ImageEntity mainImage; if (spec.getMainImage() == null) { mainImage = null; } else { mainImage = (S3ImageEntity) this.imageService.getByUsernameAndFilename( spec.getMainImage().getUsername(), spec.getMainImage().getFilename(), modifier ); } recipe.setMainImage(mainImage); recipe.setModified(OffsetDateTime.now()); return this.recipeRepository.save(recipe); } @Override @PreAuthorize("@recipeSecurity.isOwner(#id, #modifier)") public Recipe addViewer(long 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((UserEntity) viewer); entity.setViewers(viewers); return this.recipeRepository.save(entity); } @Override @PreAuthorize("@recipeSecurity.isOwner(#id, #modifier)") public Recipe removeViewer(long id, User modifier, User viewer) throws RecipeException { final Recipe entity = this.findRecipeEntity(id); final Set viewers = new HashSet<>(entity.getViewers()); viewers.remove((UserEntity) viewer); entity.setViewers(viewers); return this.recipeRepository.save(entity); } @Override @PreAuthorize("@recipeSecurity.isOwner(#id, #modifier)") public Recipe clearAllViewers(long 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(long id, User modifier) { this.recipeRepository.deleteById(id); } @Override public FullRecipeView toFullRecipeView(Recipe recipe, boolean includeRawText, @Nullable User viewer) { return this.getFullView((Recipe) recipe, includeRawText, viewer); } @Override public RecipeInfoView toRecipeInfoView(Recipe recipe, @Nullable User viewer) { return this.getInfoView((Recipe) 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); } }