diff --git a/doc/TechnicalSpecification.md b/doc/TechnicalSpecification.md
index 5e15783..c07f639 100644
--- a/doc/TechnicalSpecification.md
+++ b/doc/TechnicalSpecification.md
@@ -194,7 +194,7 @@ creation process will take place here
- [x] Recipe Creation Wizard
- [x] Create a new recipe object in the database
- [x] Recipe should be attached to a user (logged in)
- - [ ] User should be directed to the view recipe page on a successful creation
+ - [x] User should be directed to the view recipe page on a successful creation
diff --git a/internal/app/handlers/recipe_handler.go b/internal/app/handlers/recipe_handler.go
index 6756423..7854a98 100644
--- a/internal/app/handlers/recipe_handler.go
+++ b/internal/app/handlers/recipe_handler.go
@@ -34,3 +34,13 @@ func CreateRecipe(ctx *gin.Context) {
ctx.Header("HX-Redirect", url)
ctx.Status(http.StatusCreated)
}
+
+func SearchRecipes(ctx *gin.Context) {
+ deps := ctx.MustGet("deps").(*domain.InjectedDependencies)
+
+ recipes, err := deps.RecipeService.SearchRecipes(ctx)
+ if err != nil {
+ ctx.JSON(http.StatusOK, gin.H{"error": err.Error()})
+ }
+ ctx.JSON(http.StatusOK, gin.H{"recipes": recipes})
+}
diff --git a/internal/app/server/server.go b/internal/app/server/server.go
index a234450..b972deb 100644
--- a/internal/app/server/server.go
+++ b/internal/app/server/server.go
@@ -176,8 +176,8 @@ func (s *Server) Setup() *Server {
router_api.GET("/auth/logout", handlers.Logout)
// Recipe endpoints
- // TODO: This should be post. Temp!
router_api.POST("/recipe", handlers.CreateRecipe)
+ router_api.POST("/recipe/search", handlers.SearchRecipes)
return s
}
diff --git a/internal/app/service/recipe_service.go b/internal/app/service/recipe_service.go
index e1f9fe6..4057218 100644
--- a/internal/app/service/recipe_service.go
+++ b/internal/app/service/recipe_service.go
@@ -129,3 +129,47 @@ func (s *RecipeService) GetRecipe(id int) (*domain.Recipe, error) {
return recipe, err
}
+
+// toBits converts an array of stringified numbers into a single summed value
+func toBits(arr []string) (bits int) {
+ for _, x := range arr {
+ num, _ := strconv.Atoi(x)
+ bits += num
+ }
+ return
+}
+
+// isBitActive returns true when the bit at pos (0 indexed) is true.
+func isBitActive(bits, pos int) bool {
+ return (bits>>pos)&1 == 1
+}
+
+func (s *RecipeService) SearchRecipes(ctx *gin.Context) ([]domain.Recipe, error) {
+ // NOTE: How are the filters handled?
+ // Each input is given a bit value (e.g., 00001 for 1) and will be passed
+ // back to this handler as an array. The values are then added together
+ // and will result in a integer which represents bit values. These bits
+ // can then be passed to the repository and are then parsed to determine
+ // which filters should be applied.
+ // Parsing these is simple, for each filter option, use the bitwise and (&)
+ // operator with the value we expect for the filter. When 1, we can ensure
+ // the filter is provided.
+ // A function above (isBitActive) provides an example of testing of testing
+ // the filter parsing.
+
+ search := ctx.PostForm("search") // string, search query for titles
+ meal := toBits(ctx.PostFormArray("meal"))
+ time := toBits(ctx.PostFormArray("time"))
+ difficulty := toBits(ctx.PostFormArray("difficulty"))
+ serving := toBits(ctx.PostFormArray("serving"))
+
+ filters := domain.SearchFilters{
+ Search: search,
+ MealType: meal,
+ Time: time,
+ Difficulty: difficulty,
+ ServingSize: serving,
+ }
+
+ return s.recipeRepository.SearchRecipes(filters)
+}
diff --git a/internal/domain/recipe/recipe.go b/internal/domain/recipe/recipe.go
index f05b36e..1eb61fc 100644
--- a/internal/domain/recipe/recipe.go
+++ b/internal/domain/recipe/recipe.go
@@ -18,12 +18,34 @@ const (
MealBreakfast RecipeMeal = "breakfast"
MealLunch RecipeMeal = "lunch"
MealDinner RecipeMeal = "dinner"
- MealDessert RecipeMeal = "dessert"
+ MealDesert RecipeMeal = "dessert"
MealSnack RecipeMeal = "snack"
MealSide RecipeMeal = "side"
MealOther RecipeMeal = "other"
)
+// ParseMeal converts an integer value into a meal type (string). Values are 0-indexed.
+func ParseMeal(meal int) RecipeMeal {
+ switch meal {
+ case 0:
+ return MealBreakfast
+ case 1:
+ return MealLunch
+ case 2:
+ return MealDinner
+ case 3:
+ return MealDesert
+ case 4:
+ return MealSnack
+ case 5:
+ return MealSide
+ case 6:
+ return MealOther
+ default:
+ return MealOther
+ }
+}
+
// RecipeIngredient is a single ingredients in a recipe. These have JSON tags which allow them
// to be marshaled into a JSON array and stored in the database (JSONB).
type RecipeIngredient struct {
@@ -47,3 +69,14 @@ type Recipe struct {
Modified *time.Time // Pointer to allow null
Created time.Time
}
+
+// SearchFilters is a model which represents the required filters to complete a recipe search.
+// The integer values should be provided as bits and used to parse out individual flags. More
+// details can be found in the SearchRecipes service function.
+type SearchFilters struct {
+ Search string
+ MealType int
+ Time int
+ Difficulty int
+ ServingSize int
+}
diff --git a/internal/domain/recipe/repository.go b/internal/domain/recipe/repository.go
index d8eda0e..f89c026 100644
--- a/internal/domain/recipe/repository.go
+++ b/internal/domain/recipe/repository.go
@@ -3,4 +3,5 @@ package domain
type RecipeRepository interface {
CreateRecipe(recipe *Recipe) error
GetRecipe(id int) (*Recipe, error)
+ SearchRecipes(filters SearchFilters) ([]Recipe, error)
}
diff --git a/internal/domain/recipe/service.go b/internal/domain/recipe/service.go
index dc2409b..558f63f 100644
--- a/internal/domain/recipe/service.go
+++ b/internal/domain/recipe/service.go
@@ -5,4 +5,5 @@ import "github.com/gin-gonic/gin"
type RecipeService interface {
CreateRecipe(ctx *gin.Context) (*Recipe, error)
GetRecipe(id int) (*Recipe, error)
+ SearchRecipes(ctx *gin.Context) ([]Recipe, error)
}
diff --git a/internal/domain/server/routes.go b/internal/domain/server/routes.go
index 47ac188..41d9aa7 100644
--- a/internal/domain/server/routes.go
+++ b/internal/domain/server/routes.go
@@ -19,3 +19,5 @@ const WEB_RECIPE = VERSION + WEB + "/recipe/%d"
const API_AUTH_LOGIN = VERSION + API + "/auth/login"
const API_AUTH_CALLBACK = VERSION + API + "/auth/callback"
const API_AUTH_LOGOUT = VERSION + API + "/auth/logout"
+const API_CREATE_RECIPE = VERSION + API + "/recipe"
+const API_SEARCH_RECIPES = VERSION + API + "/recipe/search"
diff --git a/internal/infrastructure/database/repository/recipe_repository.go b/internal/infrastructure/database/repository/recipe_repository.go
index cfbf04d..6727448 100644
--- a/internal/infrastructure/database/repository/recipe_repository.go
+++ b/internal/infrastructure/database/repository/recipe_repository.go
@@ -4,6 +4,7 @@ import (
"database/sql"
"encoding/json"
"fmt"
+ "strings"
domain "github.com/haydenhargreaves/Potion/internal/domain/recipe"
"github.com/lib/pq"
@@ -134,6 +135,8 @@ func (r *RecipeRepository) GetRecipe(id int) (*domain.Recipe, error) {
}
recipe.Duration = duration
+ } else {
+ recipe.Duration = domain.RecipeDuration{}
}
// Parse ingredient
@@ -144,7 +147,178 @@ func (r *RecipeRepository) GetRecipe(id int) (*domain.Recipe, error) {
}
recipe.Ingredients = ingredients
+ } else {
+ recipe.Ingredients = []domain.RecipeIngredient{}
}
return &recipe, nil
}
+
+// isBitActive returns true when the bit at pos (0 indexed) is true.
+func isBitActive(bits, pos int) bool {
+ return (bits>>pos)&1 == 1
+}
+
+func (r *RecipeRepository) SearchRecipes(filters domain.SearchFilters) ([]domain.Recipe, error) {
+ tx, err := r.db.Begin()
+ if err != nil {
+ tx.Rollback()
+ return nil, err
+ }
+
+ // Generate the query
+ query := "SELECT * FROM recipes"
+
+ // Compute meals type filters (there are 7 bits)
+ var mealConditions []string
+ for i := range 7 {
+ if isBitActive(filters.MealType, i) {
+ mealConditions = append(mealConditions, fmt.Sprintf("category = '%s'", domain.ParseMeal(i)))
+ }
+ }
+
+ // Compute time filters (there are 5 bits)
+ var timeConditions []string
+ for i := range 5 {
+ var cond string
+ if isBitActive(filters.Time, i) {
+ switch i {
+ case 0:
+ cond = "(duration->>'total')::int < 15"
+ case 1:
+ cond = "(duration->>'total')::int BETWEEN 15 AND 30"
+ case 2:
+ cond = "(duration->>'total')::int BETWEEN 30 AND 60"
+ case 3:
+ cond = "(duration->>'total')::int BETWEEN 60 AND 120"
+ case 4:
+ cond = "(duration->>'total')::int > 120"
+ }
+ timeConditions = append(timeConditions, cond)
+ }
+ }
+
+ // Compute difficulty filters (there are 5 bits)
+ var difficultyConditions []string
+ for i := range 5 {
+ if isBitActive(filters.Difficulty, i) {
+ cond := fmt.Sprintf("difficulty = '%d'", i+1)
+ difficultyConditions = append(difficultyConditions, cond)
+ }
+ }
+
+ // Compute serving size filters (there are 5 bits)
+ var servingConditions []string
+ for i := range 5 {
+ var cond string
+ if isBitActive(filters.ServingSize, i) {
+ switch i {
+ case 0:
+ cond = "serves BETWEEN 1 AND 2"
+ case 1:
+ cond = "serves BETWEEN 2 AND 4"
+ case 2:
+ cond = "serves BETWEEN 4 AND 6"
+ case 3:
+ cond = "serves BETWEEN 6 AND 8"
+ case 4:
+ cond = "serves > 8"
+ }
+ servingConditions = append(servingConditions, cond)
+ }
+ }
+
+ // TODO: Title search somehow...
+
+ // Merge condition strings
+ mealString := fmt.Sprintf("(%s)", strings.Join(mealConditions, " OR "))
+ timeString := fmt.Sprintf("(%s)", strings.Join(timeConditions, " OR "))
+ difficultyString := fmt.Sprintf("(%s)", strings.Join(difficultyConditions, " OR "))
+ servingString := fmt.Sprintf("(%s)", strings.Join(servingConditions, " OR "))
+
+ // Combine condition strings
+ var conditions []string
+ if len(mealConditions) > 0 {
+ conditions = append(conditions, mealString)
+ }
+ if len(timeConditions) > 0 {
+ conditions = append(conditions, timeString)
+ }
+ if len(difficultyConditions) > 0 {
+ conditions = append(conditions, difficultyString)
+ }
+ if len(servingConditions) > 0 {
+ conditions = append(conditions, servingString)
+ }
+
+ // Convert and append conditions
+ if len(conditions) > 0 {
+ conditionsString := fmt.Sprintf("WHERE %s", strings.Join(conditions, " AND "))
+ query = fmt.Sprintf("%s %s;", query, conditionsString)
+ } else {
+ query += ";"
+ }
+
+ // Execute the query
+ rows, err := tx.Query(query)
+ if err != nil {
+ return nil, fmt.Errorf("failed to query recipes: %w", err)
+ }
+ defer rows.Close()
+
+ var recipes []domain.Recipe
+ for rows.Next() {
+ // Parsed values location
+ var recipe domain.Recipe
+ var durationBytes []byte
+ var ingredientBytes []byte
+
+ if err := rows.Scan(
+ &recipe.Id,
+ &recipe.Title,
+ &recipe.Description,
+ pq.Array(&recipe.Instructions),
+ &recipe.Serves,
+ &recipe.Difficulty,
+ &durationBytes,
+ &recipe.Category,
+ &ingredientBytes,
+ &recipe.UserId,
+ &recipe.Modified,
+ &recipe.Created,
+ ); err != nil {
+ return nil, fmt.Errorf("failed to scan recipe row: %w", err)
+ }
+
+ // Parse duration from bytes
+ if len(durationBytes) > 0 {
+ var duration domain.RecipeDuration
+ if err := json.Unmarshal(durationBytes, &duration); err != nil {
+ return nil, fmt.Errorf("failed to parse duration for recipe ID %d: %w", recipe.Id, err)
+ }
+ recipe.Duration = duration
+ } else {
+ recipe.Duration = domain.RecipeDuration{}
+ }
+
+ // Parse ingredients from bytes
+ if len(ingredientBytes) > 0 {
+ var ingredients []domain.RecipeIngredient
+ if err := json.Unmarshal(ingredientBytes, &ingredients); err != nil {
+ return nil, fmt.Errorf("failed to parse ingredients for recipe ID %d: %w", recipe.Id, err)
+ }
+ recipe.Ingredients = ingredients
+ } else {
+ recipe.Ingredients = []domain.RecipeIngredient{}
+ }
+
+ recipes = append(recipes, recipe)
+ }
+
+ if err := tx.Commit(); err != nil {
+ tx.Rollback()
+ return nil, err
+ }
+
+ return recipes, nil
+}
diff --git a/internal/templates/components/dropdowns.templ b/internal/templates/components/dropdowns.templ
index 6439394..226efc8 100644
--- a/internal/templates/components/dropdowns.templ
+++ b/internal/templates/components/dropdowns.templ
@@ -1,12 +1,15 @@
package components
-templ dropdownButton(name string) {
-
+templ dropdownButton(content, name, value string) {
+
}
templ FilterDropdown() {
@@ -30,13 +33,13 @@ templ FilterDropdown() {
Meal
- @dropdownButton("Breakfast")
- @dropdownButton("Lunch")
- @dropdownButton("Dinner")
- @dropdownButton("Desert")
- @dropdownButton("Snack")
- @dropdownButton("Side")
- @dropdownButton("Other")
+ @dropdownButton("Breakfast", "meal", "1")
+ @dropdownButton("Lunch", "meal", "2")
+ @dropdownButton("Dinner", "meal", "4")
+ @dropdownButton("Desert", "meal", "8")
+ @dropdownButton("Snack", "meal", "16")
+ @dropdownButton("Side", "meal", "32")
+ @dropdownButton("Other", "meal", "64")
@@ -44,11 +47,11 @@ templ FilterDropdown() {
Cook Time
- @dropdownButton("< 15 min")
- @dropdownButton("15 to 30 min")
- @dropdownButton("30 to 60 min")
- @dropdownButton("60 to 120 min")
- @dropdownButton("+120 min")
+ @dropdownButton("< 15 min", "time", "1")
+ @dropdownButton("15 to 30 min", "time", "2")
+ @dropdownButton("30 to 60 min", "time", "4")
+ @dropdownButton("60 to 120 min", "time", "8")
+ @dropdownButton("+120 min", "time", "16")
@@ -56,11 +59,11 @@ templ FilterDropdown() {
Difficulty
- @dropdownButton("Beginner")
- @dropdownButton("Easy")
- @dropdownButton("Intermediate")
- @dropdownButton("Challegening")
- @dropdownButton("Extreme")
+ @dropdownButton("Beginner", "difficulty", "1")
+ @dropdownButton("Easy", "difficulty", "2")
+ @dropdownButton("Intermediate", "difficulty", "4")
+ @dropdownButton("Challenging", "difficulty", "8")
+ @dropdownButton("Extreme", "difficulty", "16")
@@ -68,11 +71,11 @@ templ FilterDropdown() {
Serving Size
- @dropdownButton("1 to 2")
- @dropdownButton("1 to 4")
- @dropdownButton("4 to 6")
- @dropdownButton("6 to 8")
- @dropdownButton("8+")
+ @dropdownButton("1 to 2", "serving", "1")
+ @dropdownButton("2 to 4", "serving", "2")
+ @dropdownButton("4 to 6", "serving", "4")
+ @dropdownButton("6 to 8", "serving", "8")
+ @dropdownButton("8+", "serving", "16")
diff --git a/internal/templates/components/dropdowns_templ.go b/internal/templates/components/dropdowns_templ.go
index dde6499..e6605c4 100644
--- a/internal/templates/components/dropdowns_templ.go
+++ b/internal/templates/components/dropdowns_templ.go
@@ -8,7 +8,7 @@ package components
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
-func dropdownButton(name string) templ.Component {
+func dropdownButton(content, name, value string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
@@ -29,20 +29,46 @@ func dropdownButton(name string) templ.Component {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "