diff --git a/src/main/java/com/backend/controllers/RestaurantController.java b/src/main/java/com/backend/controllers/RestaurantController.java index ec296ca..0d7460a 100644 --- a/src/main/java/com/backend/controllers/RestaurantController.java +++ b/src/main/java/com/backend/controllers/RestaurantController.java @@ -1,10 +1,12 @@ package com.backend.controllers; import com.backend.model.dtos.CreateRestaurantRequest; +import com.backend.model.dtos.RankedRestaurantDTO; import com.backend.model.dtos.RestaurantDetailsDTO; import com.backend.model.dtos.RestaurantListDTO; import com.backend.model.dtos.UpdateRestaurantRequest; import com.backend.model.entities.Restaurant; +import com.backend.services.RecommendationService; import com.backend.services.RestaurantService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -42,6 +44,22 @@ public class RestaurantController { private final RestaurantService restaurantService; + private final RecommendationService recommendationService; + + @Operation( + summary = "Get all restaurants with ranks", + description = "Retrieves a list of all available restaurants in the system including user based ranking." + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Successfully retrieved the list of restaurants") + }) + @GetMapping("/me/recommendation") + public ResponseEntity> getAllRestaurantsWithRecommendations( + @AuthenticationPrincipal Jwt jwt) { + String userId = jwt != null ? jwt.getSubject() : null; + List restaurants = recommendationService.getRankedRestaurants(userId); + return ResponseEntity.ok(restaurants); + } @Operation( summary = "Get all restaurants", diff --git a/src/main/java/com/backend/controllers/TagController.java b/src/main/java/com/backend/controllers/TagController.java new file mode 100644 index 0000000..40ce9ca --- /dev/null +++ b/src/main/java/com/backend/controllers/TagController.java @@ -0,0 +1,40 @@ +package com.backend.controllers; + +import com.backend.model.dtos.TagDTO; +import com.backend.model.entities.DishTranslation; +import com.backend.model.entities.Tag; +import com.backend.services.TagService; +import io.swagger.v3.oas.annotations.Parameter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/tags") +@RequiredArgsConstructor +@io.swagger.v3.oas.annotations.tags.Tag(name = "Tags", description = "Endpoints for retrieving tags") +@CrossOrigin(origins = "http://localhost:3000") +public class TagController { + private final TagService tagService; + @GetMapping + public ResponseEntity> getAllTags( + @Parameter(description = "Language code (PL or EN)", required = false) + @RequestParam(value = "language", defaultValue = "EN") DishTranslation.Language language) { + return ResponseEntity.ok(tagService.getAllTags(language)); + } + + @GetMapping("/grouped") + public ResponseEntity>> getAllTagsGrouped( + @Parameter(description = "Language code (PL or EN)", required = false) + @RequestParam(value = "language", defaultValue = "EN") DishTranslation.Language language) { + return ResponseEntity.ok(tagService.getAllTagsGrouped(language)); + } + +} diff --git a/src/main/java/com/backend/controllers/UserController.java b/src/main/java/com/backend/controllers/UserController.java index 9f4881f..4f27664 100644 --- a/src/main/java/com/backend/controllers/UserController.java +++ b/src/main/java/com/backend/controllers/UserController.java @@ -1,11 +1,15 @@ package com.backend.controllers; +import com.backend.model.dtos.PreferenceRequest; +import com.backend.model.dtos.UpdatePreferencesRequest; +import com.backend.model.dtos.UserPreferenceDTO; import com.backend.model.dtos.UserProfileDTO; import com.backend.services.UserService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -14,10 +18,12 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.CrossOrigin; +import java.util.List; import java.util.UUID; @RestController @@ -87,4 +93,17 @@ public ResponseEntity removeFromFavourites( return ResponseEntity.ok().build(); } + @GetMapping + public ResponseEntity> getPreferences(@AuthenticationPrincipal Jwt jwt) { + return ResponseEntity.ok(userService.getPreferences(jwt)); + } + + + @PostMapping("/preferences") + public void addPreference( + @AuthenticationPrincipal Jwt jwt, + @RequestBody @Valid UpdatePreferencesRequest request) { + userService.updatePreferences(jwt, request); + } + } diff --git a/src/main/java/com/backend/model/dtos/AIDishDTO.java b/src/main/java/com/backend/model/dtos/AIDishDTO.java index 07c0e48..ea870dc 100644 --- a/src/main/java/com/backend/model/dtos/AIDishDTO.java +++ b/src/main/java/com/backend/model/dtos/AIDishDTO.java @@ -1,6 +1,8 @@ package com.backend.model.dtos; import com.backend.model.entities.Dish; +import com.backend.model.entities.Tag; +import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; @@ -12,16 +14,12 @@ @Getter @Setter @NoArgsConstructor +@AllArgsConstructor public class AIDishDTO { private String name; private String category; private BigDecimal price; - private Set allergens; - - public AIDishDTO(Dish dish) { - this.name = dish.getName(); - this.category = dish.getCategory().name(); - this.price = dish.getPrice().getAmount(); - this.allergens = dish.getAllergens(); - } + private Set allergens; + private Set cuisines; + private Set dietary; } diff --git a/src/main/java/com/backend/model/dtos/DishDTO.java b/src/main/java/com/backend/model/dtos/DishDTO.java index 2b8d14f..11f6b2f 100644 --- a/src/main/java/com/backend/model/dtos/DishDTO.java +++ b/src/main/java/com/backend/model/dtos/DishDTO.java @@ -1,6 +1,7 @@ package com.backend.model.dtos; import com.backend.model.entities.Dish; +import com.backend.model.entities.Tag; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; @@ -17,13 +18,13 @@ public class DishDTO { private String name; private String category; private BigDecimal price; - private Set allergens; + private Set tags; public DishDTO(Dish dish) { this.id = dish.getId(); this.name = dish.getName(); this.category = dish.getCategory().name(); this.price = dish.getPrice().getAmount(); - this.allergens = dish.getAllergens(); + this.tags = dish.getTags(); } } diff --git a/src/main/java/com/backend/model/dtos/LocalizedDishDTO.java b/src/main/java/com/backend/model/dtos/LocalizedDishDTO.java index 73c2824..afb11e6 100644 --- a/src/main/java/com/backend/model/dtos/LocalizedDishDTO.java +++ b/src/main/java/com/backend/model/dtos/LocalizedDishDTO.java @@ -4,6 +4,7 @@ import lombok.Getter; import java.math.BigDecimal; +import java.util.Set; @Getter @AllArgsConstructor @@ -11,4 +12,5 @@ public class LocalizedDishDTO { private DishDTO dish; private String displayName; private BigDecimal price; + private Set tags; } diff --git a/src/main/java/com/backend/model/dtos/PreferenceRequest.java b/src/main/java/com/backend/model/dtos/PreferenceRequest.java new file mode 100644 index 0000000..cfd69c0 --- /dev/null +++ b/src/main/java/com/backend/model/dtos/PreferenceRequest.java @@ -0,0 +1,11 @@ +package com.backend.model.dtos; + +import com.backend.model.entities.Tag; +import com.backend.model.entities.UserPreference; +import jakarta.validation.constraints.NotNull; + +public record PreferenceRequest( + @NotNull Tag.TagValue tagValue, + @NotNull UserPreference.PreferenceType preferenceType +) {} + diff --git a/src/main/java/com/backend/model/dtos/RankedRestaurantDTO.java b/src/main/java/com/backend/model/dtos/RankedRestaurantDTO.java new file mode 100644 index 0000000..0d5d97f --- /dev/null +++ b/src/main/java/com/backend/model/dtos/RankedRestaurantDTO.java @@ -0,0 +1,8 @@ +package com.backend.model.dtos; + +import java.util.List; + +public record RankedRestaurantDTO ( + RestaurantListDTO restaurant, + double score){ +} diff --git a/src/main/java/com/backend/model/dtos/TagDTO.java b/src/main/java/com/backend/model/dtos/TagDTO.java new file mode 100644 index 0000000..3d22227 --- /dev/null +++ b/src/main/java/com/backend/model/dtos/TagDTO.java @@ -0,0 +1,21 @@ +package com.backend.model.dtos; + +import com.backend.model.entities.DishTranslation; +import com.backend.model.entities.Tag; +import com.backend.model.entities.Tag.TagType; +import com.backend.model.entities.Tag.TagValue; + +public record TagDTO( + TagValue value, + TagType tagType, + String label +) { + public static TagDTO from(Tag tag, DishTranslation.Language language) { + return new TagDTO( + tag.getValue(), + tag.getTagType(), + tag.getValue().getLabel(language) + ); + } +} + diff --git a/src/main/java/com/backend/model/dtos/UpdatePreferencesRequest.java b/src/main/java/com/backend/model/dtos/UpdatePreferencesRequest.java new file mode 100644 index 0000000..c8385bb --- /dev/null +++ b/src/main/java/com/backend/model/dtos/UpdatePreferencesRequest.java @@ -0,0 +1,10 @@ +package com.backend.model.dtos; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; + +import java.util.List; + +public record UpdatePreferencesRequest( + @NotNull @Valid List preferences +) {} diff --git a/src/main/java/com/backend/model/dtos/UserPreferenceDTO.java b/src/main/java/com/backend/model/dtos/UserPreferenceDTO.java new file mode 100644 index 0000000..2e479fa --- /dev/null +++ b/src/main/java/com/backend/model/dtos/UserPreferenceDTO.java @@ -0,0 +1,18 @@ +package com.backend.model.dtos; + +import com.backend.model.entities.Tag; +import com.backend.model.entities.UserPreference; + +public record UserPreferenceDTO( + Tag.TagValue tagValue, + Tag.TagType tagType, + UserPreference.PreferenceType preferenceType +) { + public static UserPreferenceDTO from(UserPreference preference) { + return new UserPreferenceDTO( + preference.getTag().getValue(), + preference.getTag().getTagType(), + preference.getPreferenceType() + ); + } +} diff --git a/src/main/java/com/backend/model/entities/Dish.java b/src/main/java/com/backend/model/entities/Dish.java index 5cce490..f759b55 100644 --- a/src/main/java/com/backend/model/entities/Dish.java +++ b/src/main/java/com/backend/model/entities/Dish.java @@ -11,6 +11,8 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.JoinTable; import jakarta.persistence.ManyToMany; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; @@ -19,6 +21,7 @@ import lombok.Setter; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.UUID; @@ -39,11 +42,13 @@ public class Dish { private Price price; - @ElementCollection(targetClass = Allergens.class) - @Enumerated(EnumType.STRING) - @CollectionTable(name = "dish_allergens") - @Column(name = "allergen") - private Set allergens; + @ManyToMany + @JoinTable( + name = "dish_tags", + joinColumns = @JoinColumn(name = "dish_id"), + inverseJoinColumns = @JoinColumn(name = "tag_id") + ) + private Set tags = new HashSet<>(); @ManyToOne private Restaurant restaurant; @@ -54,8 +59,4 @@ public class Dish { public enum Category { SOUP, MAIN_COURSE } - - public enum Allergens { - NUTS, GLUTEN, MEAT, LACTOSE, SESAME - } } diff --git a/src/main/java/com/backend/model/entities/Restaurant.java b/src/main/java/com/backend/model/entities/Restaurant.java index c0a7022..671483e 100644 --- a/src/main/java/com/backend/model/entities/Restaurant.java +++ b/src/main/java/com/backend/model/entities/Restaurant.java @@ -33,11 +33,13 @@ public class Restaurant extends Facility { private String name; @OneToMany(mappedBy = "restaurant", cascade = CascadeType.ALL, orphanRemoval = true) - private List openingHours; + private List openingHours = new ArrayList<>(); + @OneToMany(mappedBy = "restaurant", cascade = CascadeType.ALL) private List dishes = new ArrayList<>(); + @OneToMany(mappedBy = "restaurant", cascade = CascadeType.ALL) - private List dailyMenus = new ArrayList<>(); + private Set dailyMenus = new HashSet<>(); @ManyToMany @JoinTable( name = "restaurant_owners", diff --git a/src/main/java/com/backend/model/entities/Tag.java b/src/main/java/com/backend/model/entities/Tag.java new file mode 100644 index 0000000..d80024c --- /dev/null +++ b/src/main/java/com/backend/model/entities/Tag.java @@ -0,0 +1,88 @@ +package com.backend.model.entities; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.UUID; + +@Entity +@Table(name = "tags") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +//@EqualsAndHashCode(of = "id") +public class Tag { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Enumerated(EnumType.STRING) + @Column(name = "tag_value", columnDefinition = "varchar(50)") + private TagValue value; + + public TagType getTagType() { + return value.getType(); + } + + public String getName() { + return value.name(); + } + + + @Getter + public enum TagValue { + // Cuisine + ITALIAN(TagType.CUISINE, "Włoska", "Italian"), + POLISH(TagType.CUISINE, "Polska", "Polish"), + ASIAN(TagType.CUISINE, "Azjatycka", "Asian"), + FAST_FOOD(TagType.CUISINE, "Fast food", "Fast food"), + + // Allergen + NUTS(TagType.ALLERGEN, "Orzechy", "Nuts"), + GLUTEN(TagType.ALLERGEN, "Gluten", "Gluten"), + LACTOSE(TagType.ALLERGEN, "Laktoza", "Lactose"), + SESAME(TagType.ALLERGEN, "Sezam", "Sesame"), + + // Dietary + VEGAN(TagType.DIETARY, "Wegańskie", "Vegan"), + VEGETARIAN(TagType.DIETARY, "Wegetariańskie", "Vegetarian"); + + private final TagType type; + private final String labelPl; + private final String labelEn; + + TagValue(TagType type, String labelPl, String labelEn) { + this.type = type; + this.labelPl = labelPl; + this.labelEn = labelEn; + } + + public String getLabel(DishTranslation.Language language) { + return switch (language) { + case PL -> labelPl; + case EN -> labelEn; + }; + } + } + + public enum TagType { + CUISINE, + ALLERGEN, + DIETARY + } +} + diff --git a/src/main/java/com/backend/model/entities/User.java b/src/main/java/com/backend/model/entities/User.java index 6e096bb..f8e35cc 100644 --- a/src/main/java/com/backend/model/entities/User.java +++ b/src/main/java/com/backend/model/entities/User.java @@ -1,17 +1,21 @@ package com.backend.model.entities; +import jakarta.persistence.CascadeType; import jakarta.persistence.Entity; import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.JoinTable; import jakarta.persistence.ManyToMany; +import jakarta.persistence.OneToMany; import jakarta.persistence.Table; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.HashSet; +import java.util.List; import java.util.Set; import java.util.UUID; @@ -35,4 +39,19 @@ public class User { ) private Set favouriteFacilities = new HashSet<>(); + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) + private List preferences = new ArrayList<>(); + + public void addPreference(UserPreference preference) { + preferences.add(preference); + preference.setUser(this); + } + + public void removePreference(UserPreference preference) { + preferences.remove(preference); + preference.setUser(null); + } + + + } diff --git a/src/main/java/com/backend/model/entities/UserPreference.java b/src/main/java/com/backend/model/entities/UserPreference.java new file mode 100644 index 0000000..f7c2375 --- /dev/null +++ b/src/main/java/com/backend/model/entities/UserPreference.java @@ -0,0 +1,54 @@ +package com.backend.model.entities; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.UUID; + +@Entity +@Table( + name = "user_preferences", + uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "tag_id"}) +) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class UserPreference { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @ManyToOne + @JoinColumn(name = "user_id") + private User user; + + @ManyToOne + @JoinColumn(name = "tag_id") + private Tag tag; + + @Enumerated(EnumType.STRING) + private PreferenceType preferenceType; + + + public enum PreferenceType { + INCLUDE, + EXCLUDE + } +} diff --git a/src/main/java/com/backend/repositories/RestaurantRepository.java b/src/main/java/com/backend/repositories/RestaurantRepository.java index b1d48a0..381a02f 100644 --- a/src/main/java/com/backend/repositories/RestaurantRepository.java +++ b/src/main/java/com/backend/repositories/RestaurantRepository.java @@ -2,12 +2,23 @@ import com.backend.model.entities.Restaurant; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import java.time.LocalDate; import java.util.List; import java.util.UUID; @Repository public interface RestaurantRepository extends JpaRepository { List findAllByOwners_Id(String ownerId); + + @Query(""" + SELECT DISTINCT r FROM Restaurant r + LEFT JOIN FETCH r.dailyMenus dm + LEFT JOIN FETCH dm.dishes d + LEFT JOIN FETCH d.tags + """) + List findAllRestaurantsWithTodayMenu(); } diff --git a/src/main/java/com/backend/repositories/TagRepository.java b/src/main/java/com/backend/repositories/TagRepository.java new file mode 100644 index 0000000..8f9af46 --- /dev/null +++ b/src/main/java/com/backend/repositories/TagRepository.java @@ -0,0 +1,17 @@ +package com.backend.repositories; + +import com.backend.model.entities.Tag; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface TagRepository extends JpaRepository { + Optional findByValue(Tag.TagValue value); + List findAllByValueIn(Collection values); + +} diff --git a/src/main/java/com/backend/services/MenuService.java b/src/main/java/com/backend/services/MenuService.java index 04216eb..4cd63e4 100644 --- a/src/main/java/com/backend/services/MenuService.java +++ b/src/main/java/com/backend/services/MenuService.java @@ -5,8 +5,10 @@ import com.backend.model.dtos.LocalizedDailyMenuDTO; import com.backend.model.dtos.LocalizedDishDTO; import com.backend.model.dtos.MessageDTO; +import com.backend.model.dtos.TagDTO; import com.backend.model.entities.DailyMenu; import com.backend.model.entities.Dish; +import com.backend.model.entities.DishTranslation; import com.backend.model.entities.Restaurant; import com.backend.model.valueObjects.Price; import com.backend.repositories.DailyMenuRepository; @@ -65,7 +67,14 @@ public LocalizedDailyMenuDTO getLocalizedDailyMenuByRestaurantId(UUID id, String .map(dish -> new LocalizedDishDTO( dish, localizedNames.getOrDefault(dish.getId(), dish.getName()), - dish.getPrice() + dish.getPrice(), + dish.getTags().stream() + .map(tag -> new TagDTO( + tag.getValue(), + tag.getTagType(), + tag.getValue().getLabel(DishTranslation.Language.valueOf(language)) + )) + .collect(Collectors.toSet()) )) .collect(Collectors.toList()); @@ -122,7 +131,14 @@ public List getLocalizedScheduledAndActiveMenusByRestaura .map(dish -> new LocalizedDishDTO( dish, localizedNames.getOrDefault(dish.getId(), dish.getName()), - dish.getPrice() + dish.getPrice(), + dish.getTags().stream() + .map(tag -> new TagDTO( + tag.getValue(), + tag.getTagType(), + tag.getValue().getLabel(DishTranslation.Language.valueOf(language)) + )) + .collect(Collectors.toSet()) )) .collect(Collectors.toList()) )) @@ -247,7 +263,7 @@ public void updateAndApproveMenu(UUID restaurantId, DailyMenuDTO request, String dish.setCategory(Dish.Category.valueOf(dto.getCategory())); dish.setName(dto.getName()); dish.setPrice(new Price(dto.getPrice(), "PLN")); - dish.setAllergens(dto.getAllergens()); + dish.setTags(dto.getTags()); dishRepository.save(dish); dishTranslationService.upsertBothLanguages(dish, dto.getName(), sourceLanguageHeader); //aiservice.index(dish) diff --git a/src/main/java/com/backend/services/ProdAIService.java b/src/main/java/com/backend/services/ProdAIService.java index 2f09f73..354f90b 100644 --- a/src/main/java/com/backend/services/ProdAIService.java +++ b/src/main/java/com/backend/services/ProdAIService.java @@ -2,8 +2,10 @@ import com.backend.model.dtos.AIDishDTO; import com.backend.model.entities.Dish; +import com.backend.model.entities.Tag; import com.backend.model.valueObjects.Price; import com.backend.repositories.DishRepository; +import com.backend.repositories.TagRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -18,8 +20,12 @@ import org.springframework.ai.chat.client.ChatClient; +import java.util.HashSet; import java.util.List; import java.util.ArrayList; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -28,6 +34,7 @@ public class ProdAIService implements MenuAIService { private final ChatClient.Builder chatClientBuilder; private final DishRepository dishRepository; + private final TagRepository tagRepository; public List parseMenuFromImage(byte[] imageBytes) { @@ -42,6 +49,8 @@ public List parseMenuFromImage(byte[] imageBytes) { 2. category: Either "SOUP" or "MAIN_COURSE" (string, required) 3. price: Numeric value with 2 decimal places (number, required) 4. allergens: Array of allergen codes (array, can be empty) + 5. cuisines: Array of cuisine types (array, can be empty) + 6. dietary: Array of dietary types (array, can be empty) CATEGORY RULES (choose one): - "SOUP": zupy, rosół, barszcz, krem, chłodnik, zupa, bulion, consommé @@ -53,13 +62,52 @@ CATEGORY RULES (choose one): - If price is unclear or missing: use 0.00 - Remove any currency symbols (zł, PLN) - ALLERGEN CODES (use exact strings): - - "MEAT": kurczak, wołowina, wieprzowina, schabowy, kotlet, ryba, łosoś, krewetki, etc. + ALLOWED TAG VALUES (STRICT - use ONLY these exact strings): + + ALLERGENS: + - "GLUTEN" + - "LACTOSE" + - "NUTS" + + CUISINES: + - "ITALIAN" + - "POLISH" + - "ASIAN" + - "FAST_FOOD" + + DIETARY: + - "VEGAN" + - "VEGETARIAN" + + TAGGING RULES: + - Use ONLY values from the lists above (no variations, no translations) + - If unsure → return empty array [] + - Prefer empty array [] over incorrect tag + - Do NOT invent new tags + - Do NOT mix categories (e.g. GLUTEN must NOT appear in cuisines) + - Try to always pick a cuisine + + ALLERGEN HINTS: - "GLUTEN": makaron, spaghetti, pierogi, kluski, panierowany, w panierce, pieczywo - "LACTOSE": ser, śmietana, kremowy, mleko, masło, parmezan, mozzarella - "NUTS": orzechy, migdały, orzeszki, pistacje - - Use empty array [] if no allergens apply. + + CUISINE HINTS: + - "POLISH": schabowy, pierogi, rosół, bigos + - "ITALIAN": pizza, spaghetti, pasta, makaron + - "ASIAN": curry, ramen, sushi, stir-fry, noodles + - "FAST_FOOD": burger, frytki, hot-dog + + DIETARY HINTS: + - "VEGAN": Use ONLY if the dish contains NO animal products. + This means: + - NO meat (kurczak, wołowina, wieprzowina, ryba, etc.) + - NO dairy (ser, mleko, śmietana, masło) + - NO eggs + If there is ANY doubt → do NOT assign. + - "VEGETARIAN": Use if the dish contains NO meat or fish, but MAY contain dairy or eggs. + Examples: dishes with cheese, cream, or eggs are vegetarian but NOT vegan. + If meat or fish is present → DO NOT assign. EXAMPLES OF EXPECTED OUTPUT: [ @@ -126,9 +174,59 @@ private Dish convertToEntity(AIDishDTO dto) { dish.setName(dto.getName()); dish.setCategory(Dish.Category.valueOf(dto.getCategory())); dish.setPrice(new Price(dto.getPrice(), "PLN")); - dish.setAllergens(dto.getAllergens()); + + Set tags = new HashSet<>(); + + // CUISINE + if (dto.getCuisines() != null) { + tags.addAll(mapTags(dto.getCuisines(),Tag.TagType.CUISINE)); + } + + // ALLERGENS + if (dto.getAllergens() != null) { + tags.addAll(mapTags(dto.getAllergens(), Tag.TagType.ALLERGEN)); + } + + // DIETARY + if (dto.getDietary() != null) { + tags.addAll(mapTags(dto.getDietary(), Tag.TagType.DIETARY)); + } + + dish.setTags(tags); + return dish; } + + private Set mapTags(Set values, Tag.TagType expectedType) { + if (values == null || values.isEmpty()) { + return Set.of(); + } + + return values.stream() + .map(v -> { + try { + Tag.TagValue enumValue = Tag.TagValue.valueOf(v); + + if (enumValue.getType() != expectedType) { + log.warn("LLM returned wrong tag type: {} (expected: {}, actual: {})", + v, expectedType, enumValue.getType()); + return null; + } + + return tagRepository.findByValue(enumValue) + .orElseGet(() -> { + log.warn("Tag not found in DB (skipping): {}", v); + return null; + }); + + } catch (IllegalArgumentException e) { + log.warn("Invalid tag from LLM (skipping): {}", v); + return null; + } + }) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + } } diff --git a/src/main/java/com/backend/services/RecommendationService.java b/src/main/java/com/backend/services/RecommendationService.java new file mode 100644 index 0000000..59dbc16 --- /dev/null +++ b/src/main/java/com/backend/services/RecommendationService.java @@ -0,0 +1,153 @@ +package com.backend.services; + +import com.backend.model.dtos.RankedRestaurantDTO; +import com.backend.model.dtos.RestaurantListDTO; +import com.backend.model.entities.DailyMenu; +import com.backend.model.entities.Dish; +import com.backend.model.entities.Restaurant; +import com.backend.model.entities.Tag; +import com.backend.model.entities.User; +import com.backend.model.entities.UserPreference; +import com.backend.repositories.RestaurantRepository; +import com.backend.repositories.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +@Service +@RequiredArgsConstructor +public class RecommendationService { + + private final RestaurantRepository restaurantRepository; + private final UserRepository userRepository; + + private User findUserById(String userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found")); + } + + public List getRankedRestaurants(String userId) { + User user = findUserById(userId); + + List restaurants = + restaurantRepository.findAllRestaurantsWithTodayMenu(); + + Set include = new HashSet<>(); + Set exclude = new HashSet<>(); + + for (UserPreference p : user.getPreferences()) { + if (p.getPreferenceType() == UserPreference.PreferenceType.INCLUDE) { + include.add(p.getTag()); + } else { + exclude.add(p.getTag()); + } + } + + return restaurants.stream() + .map(r -> new RankedRestaurantDTO( + new RestaurantListDTO(r), + calculateScore(r, include, exclude) + )) + .sorted(Comparator.comparingDouble(RankedRestaurantDTO::score).reversed()) + .toList(); + } + + private double calculateScore(Restaurant restaurant, + Set include, + Set exclude) { + + DailyMenu activeMenu = restaurant.getDailyMenus().stream() + .filter(dm -> dm.getStatus() == DailyMenu.Status.ACTIVE) + .findFirst() + .orElse(null); + + if (activeMenu == null) { + return -Double.MAX_VALUE; + } + + List dishes = restaurant.getTodayMenu().getDishes(); + + if (dishes == null || dishes.isEmpty()) { + return -10; + } + + double score = 0; + + int totalDishes = dishes.size(); + int allergenHits = 0; + boolean hasDietaryMatch = false; + + for (Dish dish : dishes) { + + Set tags = dish.getTags(); + + // ------------------------- + // ALLERGEN (ratio-based) + // ------------------------- + boolean hasExcluded = false; + + for (Tag t : tags) { + if (exclude.contains(t) && t.getTagType()== Tag.TagType.ALLERGEN) { + hasExcluded = true; + allergenHits++; + break; + } + } + + if (hasExcluded) { + continue; + } + + // ------------------------- + // DIETARY / INCLUDE bonus + // ------------------------- + for (Tag t : tags) { + if (include.contains(t) && t.getTagType() == Tag.TagType.DIETARY) { + score += 3; + hasDietaryMatch = true; + } + } + + // ------------------------- + // CUISINE/ALLERGEN bonus + // ------------------------- + for (Tag t : tags) { + if ((t.getTagType() == Tag.TagType.CUISINE || t.getTagType() == Tag.TagType.ALLERGEN )&& include.contains(t)) { + score += 2; + } + } + } + + // ------------------------- + // ALLERGEN penalty (ratio-based) + // ------------------------- + double allergenRatio = (double) allergenHits / totalDishes; + double allergenPenalty = allergenRatio * 20; + + // ------------------------- + // DIETARY penalty (no match) + // ------------------------- + double dietaryPenalty = 0; + + boolean hasAnyDietaryPreference = include.stream() + .anyMatch(t -> t.getTagType() == Tag.TagType.DIETARY); + + if (hasAnyDietaryPreference && !hasDietaryMatch) { + dietaryPenalty = 20; + } + + // ------------------------- + // FINAL SCORE + // ------------------------- + double finalScore = score + - allergenPenalty + - dietaryPenalty; + + return finalScore / Math.sqrt(totalDishes); + } +} \ No newline at end of file diff --git a/src/main/java/com/backend/services/TagService.java b/src/main/java/com/backend/services/TagService.java new file mode 100644 index 0000000..83d688f --- /dev/null +++ b/src/main/java/com/backend/services/TagService.java @@ -0,0 +1,34 @@ +package com.backend.services; + +import com.backend.model.dtos.TagDTO; +import com.backend.model.entities.DishTranslation; +import com.backend.model.entities.Tag.TagType; +import com.backend.repositories.TagRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class TagService { + + private final TagRepository tagRepository; + + @Transactional(readOnly = true) + public List getAllTags(DishTranslation.Language language) { + return tagRepository.findAll().stream() + .map(tag -> TagDTO.from(tag, language)) + .toList(); + } + + @Transactional(readOnly = true) + public Map> getAllTagsGrouped(DishTranslation.Language language) { + return tagRepository.findAll().stream() + .map(tag -> TagDTO.from(tag, language)) + .collect(Collectors.groupingBy(TagDTO::tagType)); + } +} diff --git a/src/main/java/com/backend/services/UserService.java b/src/main/java/com/backend/services/UserService.java index 06e6bc0..69c9c60 100644 --- a/src/main/java/com/backend/services/UserService.java +++ b/src/main/java/com/backend/services/UserService.java @@ -2,11 +2,17 @@ import com.backend.model.dtos.FacilityDTO; import com.backend.model.dtos.KeycloakUserDTO; +import com.backend.model.dtos.PreferenceRequest; import com.backend.model.dtos.RestaurantListDTO; +import com.backend.model.dtos.UpdatePreferencesRequest; +import com.backend.model.dtos.UserPreferenceDTO; import com.backend.model.dtos.UserProfileDTO; import com.backend.model.entities.Facility; +import com.backend.model.entities.Tag; import com.backend.model.entities.User; +import com.backend.model.entities.UserPreference; import com.backend.repositories.FacilityRepository; +import com.backend.repositories.TagRepository; import com.backend.repositories.UserRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -17,15 +23,18 @@ import org.springframework.web.server.ResponseStatusException; import java.util.List; +import java.util.Map; +import java.util.Set; import java.util.UUID; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @Slf4j public class UserService { private final UserRepository userRepository; - private final KeycloakService keycloakService; private final FacilityRepository facilityRepository; + private final TagRepository tagRepository; @Transactional public User getOrCreateUserFromJwt(Jwt jwt) { log.info("In filter"); @@ -112,4 +121,63 @@ public void removeFromFavourites(Jwt jwt, UUID facilityId) { user.getFavouriteFacilities().remove(facility); userRepository.save(user); } + + @Transactional + public void updatePreferences(Jwt jwt, UpdatePreferencesRequest request) { + String userId = jwt.getSubject(); + + User user = userRepository.findById(userId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found")); + + + Set requestedValues = request.preferences().stream() + .map(PreferenceRequest::tagValue) + .collect(Collectors.toSet()); + + Map tagsByValue = tagRepository.findAllByValueIn(requestedValues).stream() + .collect(Collectors.toMap(Tag::getValue, t -> t)); + + Map requested = request.preferences().stream() + .collect(Collectors.toMap(PreferenceRequest::tagValue, PreferenceRequest::preferenceType)); + + + Map existing = user.getPreferences().stream() + .collect(Collectors.toMap(p -> p.getTag().getValue(), p -> p)); + + // delete preferences not included in request + existing.entrySet().stream() + .filter(e -> !requested.containsKey(e.getKey())) + .forEach(e -> user.removePreference(e.getValue())); + + // add new or update + requested.forEach((tagValue, preferenceType) -> { + if (existing.containsKey(tagValue)) { + // type changed (INCLUDE -> EXCLUDE) + existing.get(tagValue).setPreferenceType(preferenceType); + } else { + Tag tag = tagsByValue.get(tagValue); + if (tag == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown tag: " + tagValue); + } + user.addPreference(UserPreference.builder() + .user(user) + .tag(tag) + .preferenceType(preferenceType) + .build()); + } + }); + } + + + public List getPreferences(Jwt jwt) { + String userId = jwt.getSubject(); + + User user = userRepository.findById(userId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found")); + + return user.getPreferences().stream() + .map(UserPreferenceDTO::from) + .toList(); + } + } \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 6d6fd53..9c559ed 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,5 +1,5 @@ spring.application.name=backend -spring.profiles.active=dev +#spring.profiles.active=dev spring.ai.openai.api-key=${SPRING_AI_OPENAI_API_KEY} spring.ai.openai.chat.options.model=gpt-4o-mini management.endpoints.web.exposure.include=health,info,prometheus diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql index bfdaf0d..121e5f5 100644 --- a/src/main/resources/data.sql +++ b/src/main/resources/data.sql @@ -141,45 +141,60 @@ INSERT INTO daily_menu_dishes (daily_menu_id, dish_id) VALUES ('c5555555-aaaa-bbbb-cccc-555555555555', 'd5555555-aaaa-bbbb-cccc-000000000003'); ------------------------------------------------------------ --- DISH ALLERGENS +-- DISH TAGS ------------------------------------------------------------ --- Bistro Świetlica allergens -INSERT INTO dish_allergens (dish_id, allergen) VALUES -('d1111111-aaaa-bbbb-cccc-000000000001', 'GLUTEN'), -- Potato Dumplings (contains flour) -('d1111111-aaaa-bbbb-cccc-000000000001', 'LACTOSE'), -- Potato Dumplings (may contain dairy) -('d1111111-aaaa-bbbb-cccc-000000000002', 'LACTOSE'); -- Red Beet Soup (may contain sour cream) +INSERT INTO tags (id, tag_value) VALUES + -- Cuisine + ('a0000000-0000-0000-0000-000000000001', 'ITALIAN'), + ('a0000000-0000-0000-0000-000000000002', 'POLISH'), + ('a0000000-0000-0000-0000-000000000003', 'ASIAN'), + ('a0000000-0000-0000-0000-000000000004', 'FAST_FOOD'), + + -- Allergen + ('a0000000-0000-0000-0000-000000000005', 'NUTS'), + ('a0000000-0000-0000-0000-000000000006', 'GLUTEN'), + ('a0000000-0000-0000-0000-000000000007', 'LACTOSE'), + ('a0000000-0000-0000-0000-000000000010', 'SESAME'), + + -- Dietary + ('a0000000-0000-0000-0000-000000000008', 'VEGAN'), + ('a0000000-0000-0000-0000-000000000009', 'VEGETARIAN'); + +-- Bistro Świetlica tags +INSERT INTO dish_tags (dish_id, tag_id) VALUES +('d1111111-aaaa-bbbb-cccc-000000000001', 'a0000000-0000-0000-0000-000000000006'), -- Potato Dumplings (contains flour) +('d1111111-aaaa-bbbb-cccc-000000000001', 'a0000000-0000-0000-0000-000000000007'), -- Potato Dumplings (may contain dairy) +('d1111111-aaaa-bbbb-cccc-000000000002', 'a0000000-0000-0000-0000-000000000007'); -- Red Beet Soup (may contain sour cream) -- Pumpkin Cream Soup has no major allergens in this list --- Bistro by Jelonek allergens -INSERT INTO dish_allergens (dish_id, allergen) VALUES -('d2222222-aaaa-bbbb-cccc-000000000001', 'GLUTEN'), -- Chicken Teriyaki Bowl (contains soy sauce) -('d2222222-aaaa-bbbb-cccc-000000000001', 'MEAT'), -- Chicken Teriyaki Bowl (contains chicken) -('d2222222-aaaa-bbbb-cccc-000000000002', 'GLUTEN'), -- Miso Soup (contains miso paste) -('d2222222-aaaa-bbbb-cccc-000000000003', 'MEAT'); -- Salmon Rice Bowl (contains fish/meat) - --- Bistro4mat allergens -INSERT INTO dish_allergens (dish_id, allergen) VALUES -('d3333333-aaaa-bbbb-cccc-000000000001', 'GLUTEN'), -- Chicken Wrap (contains tortilla) -('d3333333-aaaa-bbbb-cccc-000000000001', 'MEAT'), -- Chicken Wrap (contains chicken) -('d3333333-aaaa-bbbb-cccc-000000000001', 'LACTOSE'), -- Chicken Wrap (may contain cheese/sauce) -('d3333333-aaaa-bbbb-cccc-000000000002', 'LACTOSE'), -- Tomato Cream Soup (contains cream) -('d3333333-aaaa-bbbb-cccc-000000000003', 'LACTOSE'); -- Greek Salad (contains feta cheese) - --- Bistro 11 allergens -INSERT INTO dish_allergens (dish_id, allergen) VALUES -('d4444444-aaaa-bbbb-cccc-000000000001', 'MEAT'), -- Pork Chop (contains pork) -('d4444444-aaaa-bbbb-cccc-000000000002', 'MEAT'); -- Chicken Broth Soup (contains chicken) --- Boiled Potatoes have no major allergens in this list - --- Neon Bistro allergens -INSERT INTO dish_allergens (dish_id, allergen) VALUES -('d5555555-aaaa-bbbb-cccc-000000000001', 'GLUTEN'), -- Caesar Salad (contains croutons) -('d5555555-aaaa-bbbb-cccc-000000000001', 'LACTOSE'), -- Caesar Salad (contains parmesan cheese) -('d5555555-aaaa-bbbb-cccc-000000000001', 'MEAT'), -- Caesar Salad (usually contains chicken) -('d5555555-aaaa-bbbb-cccc-000000000002', 'LACTOSE'), -- Broccoli Cream Soup (contains cream) -('d5555555-aaaa-bbbb-cccc-000000000003', 'MEAT'), -- Tuna Roll (contains fish/meat) -('d5555555-aaaa-bbbb-cccc-000000000003', 'GLUTEN'); -- Tuna Roll (may contain soy sauce or tempura) +-- Bistro by Jelonek tags +INSERT INTO dish_tags (dish_id, tag_id) VALUES +('d2222222-aaaa-bbbb-cccc-000000000001', 'a0000000-0000-0000-0000-000000000006'), -- Chicken Teriyaki Bowl (contains soy sauce) +('d2222222-aaaa-bbbb-cccc-000000000001', 'a0000000-0000-0000-0000-000000000003'), -- Chicken Teriyaki Bowl (Asian)) +('d2222222-aaaa-bbbb-cccc-000000000002', 'a0000000-0000-0000-0000-000000000006'), -- Miso Soup (contains miso paste) +('d2222222-aaaa-bbbb-cccc-000000000002', 'a0000000-0000-0000-0000-000000000003'), -- Miso Soup (Asian) +('d2222222-aaaa-bbbb-cccc-000000000003', 'a0000000-0000-0000-0000-000000000003'); -- Salmon Rice Bowl (Asian) + +-- Bistro4mat tags +INSERT INTO dish_tags (dish_id, tag_id) VALUES +('d3333333-aaaa-bbbb-cccc-000000000001', 'a0000000-0000-0000-0000-000000000006'), -- Chicken Wrap (contains tortilla) +('d3333333-aaaa-bbbb-cccc-000000000001', 'a0000000-0000-0000-0000-000000000007'), -- Chicken Wrap (may contain cheese/sauce) +('d3333333-aaaa-bbbb-cccc-000000000002', 'a0000000-0000-0000-0000-000000000007'), -- Tomato Cream Soup (contains cream) +('d3333333-aaaa-bbbb-cccc-000000000003', 'a0000000-0000-0000-0000-000000000007'); -- Greek Salad (contains feta cheese) + +-- Bistro 11 tags +INSERT INTO dish_tags (dish_id, tag_id) VALUES +('d4444444-aaaa-bbbb-cccc-000000000003', 'a0000000-0000-0000-0000-000000000008'), -- Boiled Potatoes (Vegan) +('d4444444-aaaa-bbbb-cccc-000000000003', 'a0000000-0000-0000-0000-000000000009'); -- Boiled Potatoes (Vegetarian) + +-- Neon Bistro tags +INSERT INTO dish_tags (dish_id, tag_id) VALUES +('d5555555-aaaa-bbbb-cccc-000000000001', 'a0000000-0000-0000-0000-000000000006'), -- Caesar Salad (contains croutons) +('d5555555-aaaa-bbbb-cccc-000000000001', 'a0000000-0000-0000-0000-000000000007'), -- Caesar Salad (contains parmesan cheese) +('d5555555-aaaa-bbbb-cccc-000000000002', 'a0000000-0000-0000-0000-000000000007'), -- Broccoli Cream Soup (contains cream) +('d5555555-aaaa-bbbb-cccc-000000000003', 'a0000000-0000-0000-0000-000000000003'), -- Tuna Roll (Asian) +('d5555555-aaaa-bbbb-cccc-000000000003', 'a0000000-0000-0000-0000-000000000006'); -- Tuna Roll (may contain soy sauce or tempura) ------------------------------------------------------------ -- VENDING MACHINES' LOCATIONS (might require more detailed in person mapping) @@ -274,11 +289,10 @@ VALUES ( 'eeeeeeee-1111-1111-1111-eeeeeeeeeeee' ); -INSERT INTO dish_allergens (dish_id, allergen) +INSERT INTO dish_tags (dish_id, tag_id) VALUES - ('eeeeeeee-1111-1111-1111-eeeeeeeeeeee', 'GLUTEN'), - ('eeeeeeee-1111-1111-1111-eeeeeeeeeeee', 'LACTOSE'), - ('eeeeeeee-1111-1111-1111-eeeeeeeeeeee', 'MEAT'); + ('eeeeeeee-1111-1111-1111-eeeeeeeeeeee', 'a0000000-0000-0000-0000-000000000006'), --gluten + ('eeeeeeee-1111-1111-1111-eeeeeeeeeeee', 'a0000000-0000-0000-0000-000000000007'); --lactose INSERT INTO restaurant_owners (restaurant_id, user_id) VALUES ( diff --git a/src/main/resources/db/migration/V4__tag_chema.sql b/src/main/resources/db/migration/V4__tag_chema.sql new file mode 100644 index 0000000..ee93984 --- /dev/null +++ b/src/main/resources/db/migration/V4__tag_chema.sql @@ -0,0 +1,23 @@ +CREATE TABLE tags ( + id UUID NOT NULL PRIMARY KEY, + tag_value VARCHAR(50) NOT NULL UNIQUE +); + +CREATE TABLE dish_tags ( + dish_id UUID NOT NULL, + tag_id UUID NOT NULL, + PRIMARY KEY (dish_id, tag_id) +); + +CREATE TABLE user_preferences ( + id UUID NOT NULL PRIMARY KEY, + user_id VARCHAR(255) NOT NULL, + tag_id UUID NOT NULL, + preference_type VARCHAR(20) NOT NULL CHECK (preference_type IN ('INCLUDE', 'EXCLUDE')), + CONSTRAINT uq_user_tag UNIQUE (user_id, tag_id) +); + +alter table if exists dish_tags add constraint fk_dish_tags_dish foreign key (dish_id) references dish; +alter table if exists dish_tags add constraint fk_dish_tags_tag foreign key (tag_id) references tags; +alter table if exists user_preferences add constraint fk_user_preferences_user foreign key (user_id) references keycloak_user; +alter table if exists user_preferences add constraint fk_user_preferences_tag foreign key (tag_id) references tags; \ No newline at end of file diff --git a/src/main/resources/db/migration/V5__seed_tags.sql b/src/main/resources/db/migration/V5__seed_tags.sql new file mode 100644 index 0000000..dcfec9e --- /dev/null +++ b/src/main/resources/db/migration/V5__seed_tags.sql @@ -0,0 +1,16 @@ +INSERT INTO tags (id, tag_value) VALUES + -- Cuisine + ('a0000000-0000-0000-0000-000000000001', 'ITALIAN'), + ('a0000000-0000-0000-0000-000000000002', 'POLISH'), + ('a0000000-0000-0000-0000-000000000003', 'ASIAN'), + ('a0000000-0000-0000-0000-000000000004', 'FAST_FOOD'), + + -- Allergen + ('a0000000-0000-0000-0000-000000000005', 'NUTS'), + ('a0000000-0000-0000-0000-000000000006', 'GLUTEN'), + ('a0000000-0000-0000-0000-000000000007', 'LACTOSE'), + + -- Dietary + ('a0000000-0000-0000-0000-000000000008', 'VEGAN'), + ('a0000000-0000-0000-0000-000000000009', 'VEGETARIAN') +ON CONFLICT (id) DO NOTHING; \ No newline at end of file diff --git a/src/main/resources/db/migration/V6__remove_dish_allergens.sql b/src/main/resources/db/migration/V6__remove_dish_allergens.sql new file mode 100644 index 0000000..3a9d64c --- /dev/null +++ b/src/main/resources/db/migration/V6__remove_dish_allergens.sql @@ -0,0 +1,2 @@ +ALTER TABLE dish DROP CONSTRAINT IF EXISTS FKm9q52lhbluv5a316ehhmhb57u; +DROP TABLE dish_allergens; \ No newline at end of file diff --git a/src/main/resources/db/migration/V7__add_sesame_tag.sql b/src/main/resources/db/migration/V7__add_sesame_tag.sql new file mode 100644 index 0000000..a72c54a --- /dev/null +++ b/src/main/resources/db/migration/V7__add_sesame_tag.sql @@ -0,0 +1,3 @@ +INSERT INTO tags (id, tag_value) VALUES + ('a0000000-0000-0000-0000-000000000010', 'SESAME') +ON CONFLICT (id) DO NOTHING; \ No newline at end of file diff --git a/src/test/java/com/backend/controllersTests/RestaurantControllerTest.java b/src/test/java/com/backend/controllersTests/RestaurantControllerTest.java index cb939e2..b10bd99 100644 --- a/src/test/java/com/backend/controllersTests/RestaurantControllerTest.java +++ b/src/test/java/com/backend/controllersTests/RestaurantControllerTest.java @@ -4,6 +4,7 @@ import com.backend.filters.SaveUserFilter; import com.backend.model.dtos.RestaurantDetailsDTO; import com.backend.model.entities.Restaurant; +import com.backend.services.RecommendationService; import com.backend.services.RestaurantService; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -39,6 +40,9 @@ class RestaurantControllerTest { @MockitoBean private RestaurantService restaurantService; + @MockitoBean + private RecommendationService recommendationService; + @Test void shouldReturnRestaurantById() throws Exception { diff --git a/src/test/java/com/backend/model/MenuProcessingListenerTest.java b/src/test/java/com/backend/model/MenuProcessingListenerTest.java index eaea5f2..d6ae1b4 100644 --- a/src/test/java/com/backend/model/MenuProcessingListenerTest.java +++ b/src/test/java/com/backend/model/MenuProcessingListenerTest.java @@ -58,7 +58,7 @@ void shouldCreateDraftAfterSuccessfulAiProcessing() { Dish parsedDish = new Dish(); parsedDish.setName("Tomato soup"); parsedDish.setCategory(Dish.Category.SOUP); - parsedDish.setAllergens(Set.of()); + parsedDish.setTags(Set.of()); MessageDTO message = new MessageDTO(menuId, restaurantId, LocalDate.now(), new byte[] {1, 2, 3}, "menu.jpg", "owner-1"); diff --git a/src/test/java/com/backend/servicesTests/MenuServiceTest.java b/src/test/java/com/backend/servicesTests/MenuServiceTest.java index 18546ba..a5782e3 100644 --- a/src/test/java/com/backend/servicesTests/MenuServiceTest.java +++ b/src/test/java/com/backend/servicesTests/MenuServiceTest.java @@ -6,6 +6,7 @@ import com.backend.model.entities.DailyMenu; import com.backend.model.entities.Dish; import com.backend.model.entities.Restaurant; +import com.backend.model.entities.Tag; import com.backend.model.entities.User; import com.backend.repositories.DailyMenuRepository; import com.backend.repositories.DishRepository; @@ -246,7 +247,7 @@ void shouldUpdateAndApproveMenu() { dishDTO.setName("Pizza"); dishDTO.setCategory("MAIN_COURSE"); dishDTO.setPrice(BigDecimal.valueOf(25.0)); - dishDTO.setAllergens(Set.of(Dish.Allergens.GLUTEN)); + dishDTO.setTags(Set.of()); DailyMenuDTO request = new DailyMenuDTO(); request.setDate(LocalDate.now()); @@ -289,7 +290,10 @@ void shouldCreateMenu() { dishDTO.setName("Pizza"); dishDTO.setCategory("MAIN_COURSE"); dishDTO.setPrice(BigDecimal.valueOf(25.0)); - dishDTO.setAllergens(Set.of(Dish.Allergens.GLUTEN)); + + Tag gluten = new Tag(); + gluten.setValue(Tag.TagValue.GLUTEN); + dishDTO.setTags(Set.of(gluten)); DailyMenuDTO request = new DailyMenuDTO(); request.setDate(LocalDate.now()); @@ -376,7 +380,7 @@ void shouldScheduleFuturePublishedMenu() { dishDTO.setName("Soup"); dishDTO.setCategory("SOUP"); dishDTO.setPrice(BigDecimal.valueOf(19.0)); - dishDTO.setAllergens(Set.of()); + dishDTO.setTags(Set.of()); DailyMenuDTO request = new DailyMenuDTO(); request.setDate(futureDate); @@ -418,7 +422,11 @@ void shouldAllowEditingScheduledMenuById() { dishDTO.setName("Edited Pizza"); dishDTO.setCategory("MAIN_COURSE"); dishDTO.setPrice(BigDecimal.valueOf(31.0)); - dishDTO.setAllergens(Set.of(Dish.Allergens.GLUTEN)); + + Tag gluten = new Tag(); + gluten.setValue(Tag.TagValue.GLUTEN); + + dishDTO.setTags(Set.of(gluten)); DailyMenuDTO request = new DailyMenuDTO(); request.setId(menuId); diff --git a/src/test/java/com/backend/servicesTests/RecommendationServiceTest.java b/src/test/java/com/backend/servicesTests/RecommendationServiceTest.java new file mode 100644 index 0000000..21f01f1 --- /dev/null +++ b/src/test/java/com/backend/servicesTests/RecommendationServiceTest.java @@ -0,0 +1,181 @@ +package com.backend.servicesTests; + +import com.backend.model.dtos.RankedRestaurantDTO; +import com.backend.model.entities.*; +import com.backend.repositories.RestaurantRepository; +import com.backend.repositories.UserRepository; +import com.backend.services.RecommendationService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Set; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class RecommendationServiceTest { + + @Mock + private RestaurantRepository restaurantRepository; + + @Mock + private UserRepository userRepository; + + @InjectMocks + private RecommendationService recommendationService; + + private User user; + + @BeforeEach + void setup() { + user = new User(); + user.setId(UUID.randomUUID().toString()); + } + + @Test + void shouldReturnVeryLowScoreWhenNoActiveMenu() { + + Restaurant restaurant = new Restaurant(); + restaurant.setName("No active menu"); + + DailyMenu inactive = new DailyMenu(); + inactive.setStatus(DailyMenu.Status.INACTIVE); + + restaurant.setDailyMenus(Set.of(inactive)); + + when(userRepository.findById(user.getId())) + .thenReturn(java.util.Optional.of(user)); + + when(restaurantRepository.findAllRestaurantsWithTodayMenu()) + .thenReturn(List.of(restaurant)); + + List result = + recommendationService.getRankedRestaurants(user.getId()); + + assertEquals(1, result.size()); + + assertEquals(-Double.MAX_VALUE, result.getFirst().score()); + } + + @Test + void shouldGiveHigherScoreForMatchingDietaryTags() { + + Tag vegan = new Tag(); + vegan.setValue(Tag.TagValue.VEGAN); + + UserPreference pref = new UserPreference(); + pref.setPreferenceType(UserPreference.PreferenceType.INCLUDE); + pref.setTag(vegan); + + user.setPreferences(List.of(pref)); + + Dish dish = new Dish(); + dish.setTags(Set.of(vegan)); + + DailyMenu menu = new DailyMenu(); + menu.setStatus(DailyMenu.Status.ACTIVE); + menu.setDishes(List.of(dish)); + + Restaurant restaurant = new Restaurant(); + restaurant.setDailyMenus(Set.of(menu)); + + when(userRepository.findById(user.getId())) + .thenReturn(java.util.Optional.of(user)); + + when(restaurantRepository.findAllRestaurantsWithTodayMenu()) + .thenReturn(List.of(restaurant)); + + List result = + recommendationService.getRankedRestaurants(user.getId()); + + assertTrue(result.getFirst().score() > 0); + } + + @Test + void shouldPenalizeExcludedTags() { + + Tag nuts = new Tag(); + nuts.setValue(Tag.TagValue.NUTS); + + UserPreference pref = new UserPreference(); + pref.setPreferenceType(UserPreference.PreferenceType.EXCLUDE); + pref.setTag(nuts); + + user.setPreferences(List.of(pref)); + + Dish dish = new Dish(); + dish.setTags(Set.of(nuts)); + + DailyMenu menu = new DailyMenu(); + menu.setStatus(DailyMenu.Status.ACTIVE); + menu.setDishes(List.of(dish)); + + Restaurant restaurant = new Restaurant(); + restaurant.setDailyMenus(Set.of(menu)); + + when(userRepository.findById(user.getId())) + .thenReturn(java.util.Optional.of(user)); + + when(restaurantRepository.findAllRestaurantsWithTodayMenu()) + .thenReturn(List.of(restaurant)); + + List result = + recommendationService.getRankedRestaurants(user.getId()); + + assertTrue(result.getFirst().score() < 0); + } + + @Test + void shouldSortRestaurantsByScoreDescending() { + + Tag vegan = new Tag(); + vegan.setValue(Tag.TagValue.VEGAN); + + UserPreference pref = new UserPreference(); + pref.setPreferenceType(UserPreference.PreferenceType.INCLUDE); + pref.setTag(vegan); + + user.setPreferences(List.of(pref)); + + Dish matchingDish = new Dish(); + matchingDish.setTags(Set.of(vegan)); + + Dish nonMatchingDish = new Dish(); + nonMatchingDish.setTags(Set.of()); + + DailyMenu active1 = new DailyMenu(); + active1.setStatus(DailyMenu.Status.ACTIVE); + active1.setDishes(List.of(matchingDish)); + + DailyMenu active2 = new DailyMenu(); + active2.setStatus(DailyMenu.Status.ACTIVE); + active2.setDishes(List.of(nonMatchingDish)); + + Restaurant better = new Restaurant(); + better.setName("Better"); + better.setDailyMenus(Set.of(active1)); + + Restaurant worse = new Restaurant(); + worse.setName("Worse"); + worse.setDailyMenus(Set.of(active2)); + + when(userRepository.findById(user.getId())) + .thenReturn(java.util.Optional.of(user)); + + when(restaurantRepository.findAllRestaurantsWithTodayMenu()) + .thenReturn(List.of(worse, better)); + + List result = + recommendationService.getRankedRestaurants(user.getId()); + + assertEquals("Better", + result.get(0).restaurant().getName()); + } +} \ No newline at end of file diff --git a/src/test/java/com/backend/servicesTests/RestaurantServiceTest.java b/src/test/java/com/backend/servicesTests/RestaurantServiceTest.java index 72d3a33..19ff799 100644 --- a/src/test/java/com/backend/servicesTests/RestaurantServiceTest.java +++ b/src/test/java/com/backend/servicesTests/RestaurantServiceTest.java @@ -84,7 +84,7 @@ void setUp() { dailyMenu.setId(UUID.randomUUID()); dailyMenu.setStatus(DailyMenu.Status.ACTIVE); - List dailyMenus = new ArrayList<>(); + Set dailyMenus = new HashSet<>(); dailyMenus.add(dailyMenu); restaurant.setDailyMenus(dailyMenus);