diff --git a/internal/app/server/recipe_handler_v2.go b/internal/app/server/recipe_handler_v2.go index f7b8148..e7b5730 100644 --- a/internal/app/server/recipe_handler_v2.go +++ b/internal/app/server/recipe_handler_v2.go @@ -12,8 +12,9 @@ import ( // GetRecipeOfTheWeekHandler fetchs the current recipe of the week and returns it. // If an error occurs, it will be returned and a recipe will not be returned. // -// Until auth is reimplemented, there is no way to determine what user is making the -// call. +// BUG: Until auth is reimplemented, there is no way to determine what user is making the +// call. +// NOTE: I believe this issue has been resolved func (s *Server) GetRecipeOfTheWeekHandlerV2(ctx *gin.Context) { userId := getUserId(ctx) recipe, err := s.deps.RecipeService.GetRecipeOfTheWeek(userId) @@ -93,3 +94,20 @@ func (s *Server) SearchRecipeHandlerV2(ctx *gin.Context) { "recipes": recipes, }) } + +func (s *Server) CreateRecipeHandlerV2(ctx *gin.Context) { + recipe, err := s.deps.RecipeService.CreateRecipe(ctx) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{ + "status": http.StatusBadRequest, + "message": fmt.Sprintf("[ERROR] Failed to create recipe. %s", err.Error()), + }) + return + } + + ctx.JSON(http.StatusOK, gin.H{ + "status": http.StatusOK, + "message": "[OK] Successfully created new recipe.", + "recipe": recipe, + }) +} diff --git a/internal/app/server/server.go b/internal/app/server/server.go index 67ac926..f2822af 100644 --- a/internal/app/server/server.go +++ b/internal/app/server/server.go @@ -204,6 +204,7 @@ func (s *Server) Setup() *Server { router_api_v2.GET("/recipe/:id", JwtOptionalAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.GetRecipeHandlerV2) router_api_v2.GET("/recipe/of-the-week", JwtOptionalAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.GetRecipeOfTheWeekHandlerV2) router_api_v2.POST("/recipe/search", JwtOptionalAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.SearchRecipeHandlerV2) + router_api_v2.POST("/recipe", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.CreateRecipeHandlerV2) router_api_v2.GET("/auth/login", s.GetGoogleAuthUrlHandlerV2) router_api_v2.GET("/auth/callback", s.GoogleCallbackHandlerV2) diff --git a/internal/app/service/recipe_service.go b/internal/app/service/recipe_service.go index fc89a87..6ccc29f 100644 --- a/internal/app/service/recipe_service.go +++ b/internal/app/service/recipe_service.go @@ -1,11 +1,7 @@ package service import ( - "errors" "fmt" - "net/http" - "strconv" - "strings" "time" "github.com/gin-gonic/gin" @@ -45,66 +41,25 @@ func (s *RecipeService) CreateRecipe(ctx *gin.Context) (*domain.Recipe, error) { return nil, fmt.Errorf("User is not logged in.") } - title := ctx.PostForm("title") - description := ctx.PostForm("description") - preparation := ctx.PostForm("preparation-time") - cook := ctx.PostForm("cook-time") - serving := ctx.PostForm("serving-size") - category := ctx.PostForm("category") - difficulty := ctx.PostForm("difficulty") - ingredients := ctx.PostFormArray("ingredients") - quantity := ctx.PostFormArray("quantity") - instructions := ctx.PostFormArray("instructions") - tags := strings.Split(ctx.PostForm("tags"), ",") userId := ctx.MustGet("userId").(int) + var req domain.CreateRecipeRequest - // Have to get the image differently - image, err := ctx.FormFile("image") - if err != nil && !errors.Is(err, http.ErrMissingFile) { - // Error getting image + if err := ctx.ShouldBindJSON(&req); err != nil { + return nil, err } - // Convert to proper values - servingInt, _ := strconv.Atoi(serving) - difficultyInt, _ := strconv.Atoi(difficulty) - prepInt, _ := strconv.Atoi(preparation) - cookInt, _ := strconv.Atoi(cook) - - var ingredientSlice []domain.RecipeIngredient - for i := range len(ingredients) { - if strings.TrimSpace(ingredients[i]) != "" { - ins := domain.RecipeIngredient{ - Name: ingredients[i], - Quantity: quantity[i], - } - - ingredientSlice = append(ingredientSlice, ins) - } - } - - var instructionSlice []string - for _, ins := range instructions { - if ins != "" { - instructionSlice = append(instructionSlice, ins) - } - } - - // Create the recipe recipe := domain.Recipe{ - Title: title, - Description: description, - Instructions: instructionSlice, - Serves: servingInt, - Difficulty: difficultyInt, - Duration: domain.RecipeDuration{ - Total: prepInt + cookInt, - Prep: prepInt, - Cook: cookInt, - }, - Category: domain.RecipeMeal(category), - Ingredients: ingredientSlice, - UserId: userId, - Created: time.Now(), + Title: req.Title, + Description: req.Description, + Instructions: req.Instructions, + Serves: req.Serves, + Difficulty: req.Difficulty, + Duration: req.Duration, + Category: req.Category, + Ingredients: req.Ingredients, + Sections: req.Sections, + UserId: userId, + Created: time.Now(), } if err := s.recipeRepository.CreateRecipe(&recipe); err != nil { @@ -112,17 +67,96 @@ func (s *RecipeService) CreateRecipe(ctx *gin.Context) (*domain.Recipe, error) { } // TODO: Upload the image - if image != nil { - } + // if req.image != nil { + // } // Create the tags - if len(tags) > 0 { - if err := s.recipeRepository.CreateRecipeTags(recipe, tags); err != nil { + if len(req.Tags) > 0 { + if err := s.recipeRepository.CreateRecipeTags(recipe, req.Tags); err != nil { return &recipe, fmt.Errorf("Failed to attach/create tags. %s\n", err.Error()) } } return &recipe, nil + + // title := ctx.PostForm("title") + // description := ctx.PostForm("description") + // preparation := ctx.PostForm("preparation-time") + // cook := ctx.PostForm("cook-time") + // serving := ctx.PostForm("serving-size") + // category := ctx.PostForm("category") + // difficulty := ctx.PostForm("difficulty") + // ingredients := ctx.PostFormArray("ingredients") + // quantity := ctx.PostFormArray("quantity") + // instructions := ctx.PostFormArray("instructions") + // tags := strings.Split(ctx.PostForm("tags"), ",") + // userId := ctx.MustGet("userId").(int) + // + // // Have to get the image differently + // image, err := ctx.FormFile("image") + // if err != nil && !errors.Is(err, http.ErrMissingFile) { + // // Error getting image + // } + // + // // Convert to proper values + // servingInt, _ := strconv.Atoi(serving) + // difficultyInt, _ := strconv.Atoi(difficulty) + // prepInt, _ := strconv.Atoi(preparation) + // cookInt, _ := strconv.Atoi(cook) + // + // var ingredientSlice []domain.RecipeIngredient + // for i := range len(ingredients) { + // if strings.TrimSpace(ingredients[i]) != "" { + // ins := domain.RecipeIngredient{ + // Name: ingredients[i], + // Quantity: quantity[i], + // } + // + // ingredientSlice = append(ingredientSlice, ins) + // } + // } + // + // var instructionSlice []string + // for _, ins := range instructions { + // if ins != "" { + // instructionSlice = append(instructionSlice, ins) + // } + // } + // + // // Create the recipe + // recipe := domain.Recipe{ + // Title: title, + // Description: description, + // Instructions: instructionSlice, + // Serves: servingInt, + // Difficulty: difficultyInt, + // Duration: domain.RecipeDuration{ + // Total: prepInt + cookInt, + // Prep: prepInt, + // Cook: cookInt, + // }, + // Category: domain.RecipeMeal(category), + // Ingredients: ingredientSlice, + // UserId: userId, + // Created: time.Now(), + // } + // + // if err := s.recipeRepository.CreateRecipe(&recipe); err != nil { + // return &recipe, err + // } + // + // // TODO: Upload the image + // if image != nil { + // } + // + // // Create the tags + // if len(tags) > 0 { + // if err := s.recipeRepository.CreateRecipeTags(recipe, tags); err != nil { + // return &recipe, fmt.Errorf("Failed to attach/create tags. %s\n", err.Error()) + // } + // } + // + // return &recipe, nil } // GetRecipe will get a recipe via its ID. Any errors will be bubbled to the caller. Furthermore, @@ -135,7 +169,7 @@ func (s *RecipeService) CreateRecipe(ctx *gin.Context) (*domain.Recipe, error) { func (s *RecipeService) GetRecipe(id int, userId *int) (*domain.Recipe, error) { recipe, err := s.recipeRepository.GetRecipe(id, userId) - if recipe == nil { + if recipe == nil && err == nil { return nil, fmt.Errorf("Recipe does not exist or has been relocated. Please try again.") } @@ -211,5 +245,14 @@ func (s *RecipeService) GetUserMadeRecipes(userId, limit int) ([]domain.Recipe, // GetRecipeOfTheWeek searches for the most recent recipe of the week. If there is not a value, // the recipe will be nil. Any errors will be bubbled to the caller. func (s *RecipeService) GetRecipeOfTheWeek(userId *int) (*domain.Recipe, error) { - return s.recipeRepository.GetRecipeOfTheWeek(userId) + id, err := s.recipeRepository.GetRecipeOfTheWeekId(userId) + if err != nil { + return nil, err + } + + if id == nil { + return nil, fmt.Errorf("[ERROR] Recipe of the week ID could not be found. It may not exist.") + } + + return s.recipeRepository.GetRecipe(*id, userId) } diff --git a/internal/domain/recipe/recipe.go b/internal/domain/recipe/recipe.go index d73585b..52ddbd6 100644 --- a/internal/domain/recipe/recipe.go +++ b/internal/domain/recipe/recipe.go @@ -46,11 +46,62 @@ func ParseMeal(meal int) RecipeMeal { } } +// TODO: Comment +type RecipeIngredientUnit string + +const ( + Blank RecipeIngredientUnit = "" + Tsp RecipeIngredientUnit = "tsp" + Tbsp RecipeIngredientUnit = "tbsp" + FlOz RecipeIngredientUnit = "fl oz" + Cup RecipeIngredientUnit = "cup" + Ml RecipeIngredientUnit = "ml" + Litre RecipeIngredientUnit = "l" + Pint RecipeIngredientUnit = "pt" + Quart RecipeIngredientUnit = "qt" + Gallon RecipeIngredientUnit = "gal" + Gram RecipeIngredientUnit = "g" + Kilogram RecipeIngredientUnit = "kg" + Ounce RecipeIngredientUnit = "oz" + Pound RecipeIngredientUnit = "lb" + Piece RecipeIngredientUnit = "piece" + Clove RecipeIngredientUnit = "clove" + Slice RecipeIngredientUnit = "slice" + Stick RecipeIngredientUnit = "stick" + Bunch RecipeIngredientUnit = "bunch" + Pinch RecipeIngredientUnit = "pinch" + Dash RecipeIngredientUnit = "dash" + Splash RecipeIngredientUnit = "splash" + ToTaste RecipeIngredientUnit = "to taste" +) + // 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 { - Name string `json:"Name"` - Quantity string `json:"Quantity"` + Id string `json:"Id"` + SectionId string `json:"SectionId"` + Name string `json:"Name"` + Amount float64 `json:"Amount"` + Unit RecipeIngredientUnit `json:"Unit"` +} + +// TODO: Comment +type RecipeInstruction struct { + Id string `json:"Id"` + Content string `json:"Content"` +} + +// TODO: Comment +type RecipeIngredientSection struct { + Id string `json:"Id"` + Name string `json:"Name"` +} + +// RecipeIngredientStore is the struct stored in the database Ingredients column. It is simply a +// combindation of the sections and the ingredients so they can be stored together. +type RecipeIngredientStore struct { + Sections []RecipeIngredientSection `json:"Sections"` + Ingredients []RecipeIngredient `json:"Ingredients"` } // Recipe is the database model of a recipe. There is no need to map to a different model so @@ -61,12 +112,13 @@ type Recipe struct { Id int Title string Description string - Instructions []string + Instructions []RecipeInstruction Serves int Difficulty int Duration RecipeDuration Category RecipeMeal - Ingredients []RecipeIngredient // Just a list of ingredients + Ingredients []RecipeIngredient + Sections []RecipeIngredientSection UserId int Modified *time.Time // Pointer to allow null Created time.Time @@ -79,11 +131,11 @@ type Recipe struct { // details can be found in the SearchRecipes service function. type SearchFilters struct { Search string `json:"Search"` - MealType int `json:"MealType"` - Time int `json:"Time"` - Difficulty int `json:"Difficulty"` - ServingSize int `json:"ServingSize"` - Favorites bool `json:"Favorites"` + MealType int `json:"MealType"` + Time int `json:"Time"` + Difficulty int `json:"Difficulty"` + ServingSize int `json:"ServingSize"` + Favorites bool `json:"Favorites"` } // Tag is a model which represents a single tag in the Tags table. A tag is mapped to a recipe @@ -102,3 +154,17 @@ type RecipeTag struct { TagId int Created time.Time } + +// TODO: Comment +type CreateRecipeRequest struct { + Title string + Description string + Instructions []RecipeInstruction + Serves int + Difficulty int + Duration RecipeDuration + Category RecipeMeal + Ingredients []RecipeIngredient + Sections []RecipeIngredientSection + Tags []string +} diff --git a/internal/domain/recipe/repository.go b/internal/domain/recipe/repository.go index 8574035..8a9aa9d 100644 --- a/internal/domain/recipe/repository.go +++ b/internal/domain/recipe/repository.go @@ -10,5 +10,5 @@ type RecipeRepository interface { GetUserFavoriteRecipes(id int) ([]Recipe, error) GetRecipeTags(recipe *Recipe) error GetRecipeFavorite(recipe *Recipe, userId int) error - GetRecipeOfTheWeek(userId *int) (*Recipe, error) + GetRecipeOfTheWeekId(userId *int) (*int, error) } diff --git a/internal/infrastructure/database/repository/recipe_repository.go b/internal/infrastructure/database/repository/recipe_repository.go index acc793f..bdaadad 100644 --- a/internal/infrastructure/database/repository/recipe_repository.go +++ b/internal/infrastructure/database/repository/recipe_repository.go @@ -46,7 +46,9 @@ func (r *RecipeRepository) CreateRecipe(recipe *domain.Recipe) error { // NOTE: Data steps // cast duration to JSON - // cast ingredients to JSON + // convert ingredients to store type + // cast store type to JSON + // extract string instructions from type // cast category to string // use nil for the modified time @@ -55,17 +57,27 @@ func (r *RecipeRepository) CreateRecipe(recipe *domain.Recipe) error { return err } - ingredientsJSON, err := json.Marshal(recipe.Ingredients) + ingredientsStore := domain.RecipeIngredientStore{ + Sections: recipe.Sections, + Ingredients: recipe.Ingredients, + } + + ingredientsJSON, err := json.Marshal(ingredientsStore) if err != nil { return err } + instructions := make([]string, len(recipe.Instructions)) + for i, instruction := range recipe.Instructions { + instructions[i] = instruction.Content + } + var id int if err = tx.QueryRow( query, recipe.Title, recipe.Description, - pq.Array(recipe.Instructions), + pq.Array(instructions), recipe.Serves, recipe.Difficulty, durationJSON, @@ -94,8 +106,7 @@ func (r *RecipeRepository) CreateRecipe(recipe *domain.Recipe) error { // for added safety. The repository will not check for a nil result, instead the service will. Callers // are responsible for protecting against double nil results. Any errors will be bubbled to the caller. func (r *RecipeRepository) GetRecipe(id int, userId *int) (*domain.Recipe, error) { - query := ` - SELECT + query := ` SELECT id, title, description, instructions, serves, difficulty, duration, category, ingredients, userid, modified, created FROM recipes @@ -103,6 +114,7 @@ func (r *RecipeRepository) GetRecipe(id int, userId *int) (*domain.Recipe, error ` var durationBytes []byte + var instructions pq.StringArray var ingredientBytes []byte var recipe domain.Recipe @@ -110,7 +122,8 @@ func (r *RecipeRepository) GetRecipe(id int, userId *int) (*domain.Recipe, error &recipe.Id, &recipe.Title, &recipe.Description, - pq.Array(&recipe.Instructions), + // pq.Array(&instructions), + &instructions, &recipe.Serves, &recipe.Difficulty, &durationBytes, @@ -137,16 +150,23 @@ func (r *RecipeRepository) GetRecipe(id int, userId *int) (*domain.Recipe, error // Parse ingredient if len(ingredientBytes) > 0 { - var ingredients []domain.RecipeIngredient - if err := json.Unmarshal(ingredientBytes, &ingredients); err != nil { + var store domain.RecipeIngredientStore + if err := json.Unmarshal(ingredientBytes, &store); err != nil { + // Check for unmarshal to support backwards compatability return nil, fmt.Errorf("Failed to parse ingredients from database: %s", err.Error()) } - recipe.Ingredients = ingredients + recipe.Ingredients = store.Ingredients + recipe.Sections = store.Sections } else { recipe.Ingredients = []domain.RecipeIngredient{} } + // Add instructions + for _, instruction := range instructions { + recipe.Instructions = append(recipe.Instructions, domain.RecipeInstruction{Content: instruction}) + } + // Add tags if err := r.GetRecipeTags(&recipe); err != nil { fmt.Printf("ERROR getting recipe tags. %s\n", err.Error()) @@ -169,83 +189,18 @@ func (r *RecipeRepository) GetRecipe(id int, userId *int) (*domain.Recipe, error // will. Callers are responsible for protecting against double nil results. Any errors will be bubbled // to the caller. func (r *RecipeRepository) GetRecipes(ids []int, userId *int) ([]domain.Recipe, error) { - query := ` - SELECT - id, title, description, instructions, serves, difficulty, duration, category, ingredients, userid, modified, created - FROM recipes - WHERE id = ANY($1) - ORDER BY array_position($1, id); - ` - var recipes []domain.Recipe - rows, err := r.db.Query(query, pq.Array(ids)) - if err != nil { - return nil, fmt.Errorf("Failed to get recipes. %s", err.Error()) - } - defer rows.Close() - - for rows.Next() { - 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 from database: %s", err.Error()) + for _, id := range ids { + recipe, err := r.GetRecipe(id, userId) + if err != nil { + return nil, err } - // Parse duration - if len(durationBytes) > 0 { - var duration domain.RecipeDuration - if err := json.Unmarshal(durationBytes, &duration); err != nil { - return nil, fmt.Errorf("Failed to parse duration from database: %s", err.Error()) - } - - recipe.Duration = duration - } else { - recipe.Duration = domain.RecipeDuration{} + // Skip any un-found recipes...? + if recipe != nil { + recipes = append(recipes, *recipe) } - - // Parse ingredient - if len(ingredientBytes) > 0 { - var ingredients []domain.RecipeIngredient - if err := json.Unmarshal(ingredientBytes, &ingredients); err != nil { - return nil, fmt.Errorf("Failed to parse ingredients from database: %s", err.Error()) - } - - recipe.Ingredients = ingredients - } else { - recipe.Ingredients = []domain.RecipeIngredient{} - } - - // Add tags - if err := r.GetRecipeTags(&recipe); err != nil { - fmt.Printf("ERROR getting recipe tags. %s\n", err.Error()) - } - - // Get favorite status, if user id is provided - if userId != nil { - if err := r.GetRecipeFavorite(&recipe, *userId); err != nil { - fmt.Printf("ERROR getting recipe favorite status. %s\n", err.Error()) - } - } else { - recipe.Favorite = false - } - - recipes = append(recipes, recipe) } return recipes, nil @@ -264,6 +219,8 @@ func isBitActive(bits, pos int) bool { // The favorites parameter is used to only return filters favorited by the userId provided. // // TODO: Pagination is required, to provide infinite scroll. +// +// TODO: This does not work in the current build, the DB does not return valid values. func (r *RecipeRepository) SearchRecipes(filters domain.SearchFilters, userId *int, favorites bool) ([]domain.Recipe, error) { // Compute meals type filters (there are 7 bits) var mealConditions []string @@ -569,10 +526,11 @@ func (r *RecipeRepository) CreateRecipeTags(recipe domain.Recipe, tags []string) // GetUserRecipes gets a list of a users owned recipes. This function does not ensure the user is // authenticated or exists. If nothing is found, a blank slice will be returned. The resulting list // is sorted by the created dates, newest first. Any errors will be bubbled to the caller. +// +// TODO: This should just return the IDs func (r *RecipeRepository) GetUserRecipes(id int) ([]domain.Recipe, error) { query := ` - SELECT id, title, description, instructions, serves, difficulty, duration, category, ingredients, - userid, modified, created + SELECT id FROM recipes WHERE userid = $1 ORDER BY created DESC; @@ -584,69 +542,21 @@ func (r *RecipeRepository) GetUserRecipes(id int) ([]domain.Recipe, error) { } defer rows.Close() - // Prepare statement for tag query - // tagQuery := ` - // ` - var recipes []domain.Recipe for rows.Next() { - var recipe domain.Recipe - var durationBytes []byte - var ingredientBytes []byte - - // Scan results from recipe query onto recipe object - 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 row onto recipe object. %s\n", err.Error()) + var r_id int + if err := rows.Scan(&r_id); err != nil { + return []domain.Recipe{}, fmt.Errorf("Failed to scan ID from db. %s\n", err.Error()) } - // Parse duration - if len(durationBytes) > 0 { - var duration domain.RecipeDuration - if err := json.Unmarshal(durationBytes, &duration); err != nil { - return nil, fmt.Errorf("Failed to parse duration from database: %s", err.Error()) - } - - recipe.Duration = duration - } else { - recipe.Duration = domain.RecipeDuration{} + recipe, err := r.GetRecipe(r_id, &id) + if err != nil { + return []domain.Recipe{}, err } - // Parse ingredient - if len(ingredientBytes) > 0 { - var ingredients []domain.RecipeIngredient - if err := json.Unmarshal(ingredientBytes, &ingredients); err != nil { - return nil, fmt.Errorf("Failed to parse ingredients from database: %s", err.Error()) - } - - recipe.Ingredients = ingredients - } else { - recipe.Ingredients = []domain.RecipeIngredient{} + if recipe != nil { + recipes = append(recipes, *recipe) } - - // Add tags - if err := r.GetRecipeTags(&recipe); err != nil { - fmt.Printf("ERROR getting recipe tags. %s\n", err.Error()) - } - - // Get favorite status - if err := r.GetRecipeFavorite(&recipe, id); err != nil { - fmt.Printf("ERROR getting recipe favorite status. %s\n", err.Error()) - } - - recipes = append(recipes, recipe) } return recipes, nil @@ -655,10 +565,11 @@ func (r *RecipeRepository) GetUserRecipes(id int) ([]domain.Recipe, error) { // GetUserRecipes gets a list of a users favorited recipes. This function does not ensure the user is // authenticated or exists. If nothing is found, a blank slice will be returned. The resulting list // is sorted by the created dates, newest first. Any errors will be bubbled to the caller. +// +// TODO: This should just return the IDs func (r *RecipeRepository) GetUserFavoriteRecipes(id int) ([]domain.Recipe, error) { query := ` - SELECT r.id, r.title, r.description, r.instructions, r.serves, r.difficulty, r.duration, r.category, r.ingredients, r. - userid, r.modified, r.created + SELECT r.id FROM favorites f JOIN recipes r ON r.id = f.recipeid WHERE f.userid = $1 @@ -672,61 +583,19 @@ func (r *RecipeRepository) GetUserFavoriteRecipes(id int) ([]domain.Recipe, erro var recipes []domain.Recipe for rows.Next() { - var recipe domain.Recipe - var durationBytes []byte - var ingredientBytes []byte - - // Scan results from recipe query onto recipe object - 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 row onto recipe object. %s\n", err.Error()) + var r_id int + if err := rows.Scan(&r_id); err != nil { + return []domain.Recipe{}, fmt.Errorf("Failed to scan ID from db. %s\n", err.Error()) } - // Parse duration - if len(durationBytes) > 0 { - var duration domain.RecipeDuration - if err := json.Unmarshal(durationBytes, &duration); err != nil { - return nil, fmt.Errorf("Failed to parse duration from database: %s", err.Error()) - } - - recipe.Duration = duration - } else { - recipe.Duration = domain.RecipeDuration{} + recipe, err := r.GetRecipe(r_id, &id) + if err != nil { + return []domain.Recipe{}, err } - // Parse ingredient - if len(ingredientBytes) > 0 { - var ingredients []domain.RecipeIngredient - if err := json.Unmarshal(ingredientBytes, &ingredients); err != nil { - return nil, fmt.Errorf("Failed to parse ingredients from database: %s", err.Error()) - } - - recipe.Ingredients = ingredients - } else { - recipe.Ingredients = []domain.RecipeIngredient{} + if recipe != nil { + recipes = append(recipes, *recipe) } - - // Add tags - if err := r.GetRecipeTags(&recipe); err != nil { - fmt.Printf("ERROR getting recipe tags. %s\n", err.Error()) - } - - // Set favorite status (they're always true!) - recipe.Favorite = true - - recipes = append(recipes, recipe) } return recipes, nil @@ -793,82 +662,27 @@ func (r *RecipeRepository) GetRecipeFavorite(recipe *domain.Recipe, userId int) return nil } -// GetRecipeOfTheWeek searches for the most recent recipe of the week. If there is not a value, +// GetRecipeOfTheWeekId searches for the most recent recipe of the week. If there is not a value, // the recipe will be nil. This function simply collects the most recent entry in the recipeoftheweek -// table and return it. If there is no entry, nil will be returned Any errors will be bubbled to -// the caller. -func (r *RecipeRepository) GetRecipeOfTheWeek(userId *int) (*domain.Recipe, error) { +// table and return it. If there is no entry, nil will be returned. Any errors will be bubbled to +// the caller. All that is returned is the recipe ID, that way the caller can handle the fetching. +func (r *RecipeRepository) GetRecipeOfTheWeekId(userId *int) (*int, error) { query := ` SELECT - r.id, r.title, r.description, r.instructions, r.serves, r.difficulty, r.duration, r.category, - r.ingredients, r.userid, r.modified, r.created + r.id FROM recipes r JOIN recipeoftheweek rw ON rw.recipeid = r.id - ORDER BY created DESC + ORDER BY rw.created DESC LIMIT 1; ` - var durationBytes []byte - var ingredientBytes []byte - - var recipe domain.Recipe - if err := r.db.QueryRow(query).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 { + var id int + if err := r.db.QueryRow(query).Scan(&id); err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, nil } return nil, fmt.Errorf("Failed to location recipe in database: %s", err.Error()) } - // Parse duration - if len(durationBytes) > 0 { - var duration domain.RecipeDuration - if err := json.Unmarshal(durationBytes, &duration); err != nil { - return nil, fmt.Errorf("Failed to parse duration from database: %s", err.Error()) - } - - recipe.Duration = duration - } else { - recipe.Duration = domain.RecipeDuration{} - } - - // Parse ingredient - if len(ingredientBytes) > 0 { - var ingredients []domain.RecipeIngredient - if err := json.Unmarshal(ingredientBytes, &ingredients); err != nil { - return nil, fmt.Errorf("Failed to parse ingredients from database: %s", err.Error()) - } - - recipe.Ingredients = ingredients - } else { - recipe.Ingredients = []domain.RecipeIngredient{} - } - - // Add tags - if err := r.GetRecipeTags(&recipe); err != nil { - fmt.Printf("ERROR getting recipe tags. %s\n", err.Error()) - } - - // Get favorite status, if user id is provided - if userId != nil { - if err := r.GetRecipeFavorite(&recipe, *userId); err != nil { - fmt.Printf("ERROR getting recipe favorite status. %s\n", err.Error()) - } - } else { - recipe.Favorite = false - } - - return &recipe, nil + return &id, nil } diff --git a/internal/templates/components/banner_templ.go b/internal/templates/components/banner_templ.go index 8d5dc82..f4a8c3e 100644 --- a/internal/templates/components/banner_templ.go +++ b/internal/templates/components/banner_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.943 +// templ: version: v0.3.960 package components //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/internal/templates/components/cards_templ.go b/internal/templates/components/cards_templ.go index 4fcd054..0f6f16c 100644 --- a/internal/templates/components/cards_templ.go +++ b/internal/templates/components/cards_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.943 +// templ: version: v0.3.960 package components //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/internal/templates/components/dropdowns_templ.go b/internal/templates/components/dropdowns_templ.go index 5f39d15..d749993 100644 --- a/internal/templates/components/dropdowns_templ.go +++ b/internal/templates/components/dropdowns_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.943 +// templ: version: v0.3.960 package components //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/internal/templates/components/error_templ.go b/internal/templates/components/error_templ.go index 1bfb31b..5055698 100644 --- a/internal/templates/components/error_templ.go +++ b/internal/templates/components/error_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.943 +// templ: version: v0.3.960 package components //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/internal/templates/components/navbar_templ.go b/internal/templates/components/navbar_templ.go index ba562fd..fde40cf 100644 --- a/internal/templates/components/navbar_templ.go +++ b/internal/templates/components/navbar_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.943 +// templ: version: v0.3.960 package components //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/internal/templates/components/search_bar_templ.go b/internal/templates/components/search_bar_templ.go index 2e78e1e..92bbc52 100644 --- a/internal/templates/components/search_bar_templ.go +++ b/internal/templates/components/search_bar_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.943 +// templ: version: v0.3.960 package components //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/internal/templates/layouts/app_layout_templ.go b/internal/templates/layouts/app_layout_templ.go index 0608ea8..f2fc957 100644 --- a/internal/templates/layouts/app_layout_templ.go +++ b/internal/templates/layouts/app_layout_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.943 +// templ: version: v0.3.960 package templates //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/internal/templates/pages/create_templ.go b/internal/templates/pages/create_templ.go index 28b33db..30930d8 100644 --- a/internal/templates/pages/create_templ.go +++ b/internal/templates/pages/create_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.943 +// templ: version: v0.3.960 package templates //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/internal/templates/pages/favorites_templ.go b/internal/templates/pages/favorites_templ.go index e888fc2..0d4c599 100644 --- a/internal/templates/pages/favorites_templ.go +++ b/internal/templates/pages/favorites_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.943 +// templ: version: v0.3.960 package templates //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/internal/templates/pages/home_templ.go b/internal/templates/pages/home_templ.go index 447abab..26d06ee 100644 --- a/internal/templates/pages/home_templ.go +++ b/internal/templates/pages/home_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.943 +// templ: version: v0.3.960 package templates //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/internal/templates/pages/list_templ.go b/internal/templates/pages/list_templ.go index 4a91ce0..e10a7d1 100644 --- a/internal/templates/pages/list_templ.go +++ b/internal/templates/pages/list_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.943 +// templ: version: v0.3.960 package templates //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/internal/templates/pages/login_templ.go b/internal/templates/pages/login_templ.go index 1cb6d29..de877a1 100644 --- a/internal/templates/pages/login_templ.go +++ b/internal/templates/pages/login_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.943 +// templ: version: v0.3.960 package templates //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/internal/templates/pages/notFound_templ.go b/internal/templates/pages/notFound_templ.go index 9ebcde1..59f71ec 100644 --- a/internal/templates/pages/notFound_templ.go +++ b/internal/templates/pages/notFound_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.943 +// templ: version: v0.3.960 package templates //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/internal/templates/pages/profile_templ.go b/internal/templates/pages/profile_templ.go index a99005c..afdf4e3 100644 --- a/internal/templates/pages/profile_templ.go +++ b/internal/templates/pages/profile_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.943 +// templ: version: v0.3.960 package templates //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/internal/templates/pages/recipe.templ b/internal/templates/pages/recipe.templ index 69d6c02..5009f9d 100644 --- a/internal/templates/pages/recipe.templ +++ b/internal/templates/pages/recipe.templ @@ -112,7 +112,7 @@ templ ingredientList(ingredients []domain.RecipeIngredient) {
@@ -308,7 +308,7 @@ templ RecipePage(recipe domain.Recipe, user domainUser.User, loggedIn bool, doma

{ recipe.Description }

@ingredientList(recipe.Ingredients) - @instructionList(recipe.Instructions) + @instructionList([]string{}) @tagList(recipe.Tags, recipe.Created, recipe.Modified) diff --git a/internal/templates/pages/recipe_templ.go b/internal/templates/pages/recipe_templ.go index 7a3ba58..d1a3b13 100644 --- a/internal/templates/pages/recipe_templ.go +++ b/internal/templates/pages/recipe_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.943 +// templ: version: v0.3.960 package templates //lint:file-ignore SA4006 This context is only used if a nested component is present. @@ -268,7 +268,7 @@ func ingredientList(ingredients []domain.RecipeIngredient) templ.Component { return templ_7745c5c3_Err } for _, ingredient := range ingredients { - templ_7745c5c3_Err = ingredientListItem(ingredient.Name, ingredient.Quantity).Render(ctx, templ_7745c5c3_Buffer) + templ_7745c5c3_Err = ingredientListItem(ingredient.Name, "").Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -875,7 +875,7 @@ func RecipePage(recipe domain.Recipe, user domainUser.User, loggedIn bool, domai if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = instructionList(recipe.Instructions).Render(ctx, templ_7745c5c3_Buffer) + templ_7745c5c3_Err = instructionList([]string{}).Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/internal/templates/pages/search_templ.go b/internal/templates/pages/search_templ.go index 2e7d66b..f90dba4 100644 --- a/internal/templates/pages/search_templ.go +++ b/internal/templates/pages/search_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.943 +// templ: version: v0.3.960 package templates //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/web/src/components/forms/IngredientItem.tsx b/web/src/components/forms/IngredientItem.tsx index df73090..768f955 100644 --- a/web/src/components/forms/IngredientItem.tsx +++ b/web/src/components/forms/IngredientItem.tsx @@ -31,7 +31,7 @@ export default function IngredientItem({ classes, ingredient, onChange, removeIn className="select-none p-2 flex gap-2 flex-col" >
-
+
onChange(section.Id, e.target.value)} - placeholder="Section title" - className="mx-2 px-2 py-1 border border-gray-300 flex-grow rounded-sm" + placeholder="Group label" + className="mx-2 px-2 py-1 border border-gray-300 flex-grow rounded-sm min-w-1" />
diff --git a/web/src/components/items/IngredientList.tsx b/web/src/components/items/IngredientList.tsx index e01eba7..98f9db9 100644 --- a/web/src/components/items/IngredientList.tsx +++ b/web/src/components/items/IngredientList.tsx @@ -1,33 +1,46 @@ -import type { RecipeIngredient } from "../../types/recipe"; +import { Fragment } from "react/jsx-runtime"; +import type { RecipeIngredient, RecipeIngredientSection } from "../../types/recipe"; interface IngredientListProps { + sections: RecipeIngredientSection[]; ingredients: RecipeIngredient[]; } -export default function IngredientList({ ingredients }: IngredientListProps) { +export default function IngredientList({ sections, ingredients }: IngredientListProps) { return ( <>

Ingredients


-
    - {ingredients?.map(ingredient => ( -
  • - - - - - - {ingredient.Amount}: {ingredient.Name} -
  • - ))} -
+ {sections?.map(section => ( + + {/* NOTE: If there is a only one section, do not display a name. */} + {sections.length > 1 && ( +

{section.Name}

+ )} +
    + {ingredients?.filter(x => x.SectionId === section.Id).map(ingredient => ( +
  • + + + + + + + {ingredient.Amount > 0 ? ingredient.Amount : null} {ingredient.Unit} + + {ingredient.Name} +
  • + ))} +
+
+ ))}
); diff --git a/web/src/components/items/InstructionList.tsx b/web/src/components/items/InstructionList.tsx index dff2331..401237c 100644 --- a/web/src/components/items/InstructionList.tsx +++ b/web/src/components/items/InstructionList.tsx @@ -1,5 +1,7 @@ +import type { RecipeInstruction } from "../../types/recipe"; + interface InstructionListProps { - instructions: string[]; + instructions: RecipeInstruction[]; } export default function InstructionList({ instructions }: InstructionListProps) { return ( @@ -9,11 +11,11 @@ export default function InstructionList({ instructions }: InstructionListProps)
    {instructions?.map((instruction, i) => ( -
  • +
  • {i + 1}

    -

    {instruction}

    +

    {instruction.Content}

  • ))}
diff --git a/web/src/pages/Create.tsx b/web/src/pages/Create.tsx index ac473de..118e290 100644 --- a/web/src/pages/Create.tsx +++ b/web/src/pages/Create.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; import Banner from "../components/Banner"; -import { type RecipeInstruction } from "../types/recipe"; +import { isRecipeMeal, type RecipeInstruction } from "../types/recipe"; import InstructionList from "../components/forms/InstructionList"; import ValidationErrorList from "../components/forms/ValidationErrorList"; import IngredientSection from "../components/forms/IngredientSection"; @@ -13,6 +13,10 @@ import RecipeCreateFormWrapper from "../components/inputs/RecipeCreateFormWrappe import RecipeCreateFormTagsInputs from "../components/inputs/RecipeCreateFormTagsInput"; import { useIngredients } from "../hooks/useIngredients"; import { validateCreateRecipeForm } from "../hooks/validation"; +import { CreateRecipe } from "../services/RecipeService"; +import type { CreateRecipeRequest } from "../types/api/recipe"; +import { isApiError } from "../types/api/error"; +import { useNavigate } from "react-router-dom"; // TODO: Move these export interface RecipeValidationEntry { @@ -118,6 +122,45 @@ export default function Create() { instructions: {}, }); + const navigate = useNavigate(); + + // Functions + const createRecipe = async (): Promise => { + console.log({ title, description, tags, prepTime, cookTime, servingSize, category, difficulty, sections, ingredients, instructions }); + + // Exit if not valid recipe meal + if (!isRecipeMeal(category)) { + console.error("[ERROR] Recipe meal is invalid."); + return; + } + + const recipe: CreateRecipeRequest = { + Title: title, + Description: description, + Instructions: instructions, + Serves: Number(servingSize), + Difficulty: Number(difficulty), + Duration: { + Prep: Number(prepTime), + Cook: Number(cookTime), + Total: Number(prepTime) + Number(cookTime) + }, + Category: category, + Ingredients: ingredients, + Sections: sections, + Tags: tags, + }; + + + const response = await CreateRecipe(recipe); + if (isApiError(response)) { + console.error(response); + return; + } + // TODO: Success toast! + await navigate(`/web/recipe/${response.Id}`); + }; + // Import ingredients const { sections, @@ -201,6 +244,8 @@ export default function Create() { }); return; } + + void createRecipe(); } @@ -381,7 +426,7 @@ export default function Create() { diff --git a/web/src/pages/Recipe.tsx b/web/src/pages/Recipe.tsx index 0c3bc12..b1c0781 100644 --- a/web/src/pages/Recipe.tsx +++ b/web/src/pages/Recipe.tsx @@ -75,7 +75,7 @@ export default function RecipePage() {

About this recipe

{recipe.Description}

- + diff --git a/web/src/services/RecipeService.ts b/web/src/services/RecipeService.ts index 11c493c..0ac614d 100644 --- a/web/src/services/RecipeService.ts +++ b/web/src/services/RecipeService.ts @@ -1,5 +1,5 @@ import axios from "axios"; -import type { GetRecipeOfTheWeekResponse, GetRecipeResponse, SearchRecipesResponse } from "../types/api/recipe"; +import type { CreateRecipeRequest, CreateRecipeResponse, GetRecipeOfTheWeekResponse, GetRecipeResponse, SearchRecipesResponse } from "../types/api/recipe"; import type { Recipe } from "../types/recipe"; import type { ApiError } from "../types/api/error"; import type { SearchFilters } from "../types/search"; @@ -46,3 +46,17 @@ export async function SearchRecipes(filters: SearchFilters): Promise { + const response = await axios.post("http://localhost:3000/v2/api/recipe", data); + + if (response.status !== 200 || response.data.recipe === undefined) { + const err: ApiError = { + status: response.data.status, + message: response.data.message + }; + return err; + } + + return response.data.recipe; +} diff --git a/web/src/types/api/recipe.ts b/web/src/types/api/recipe.ts index 0916311..814228d 100644 --- a/web/src/types/api/recipe.ts +++ b/web/src/types/api/recipe.ts @@ -1,4 +1,4 @@ -import type { Recipe } from "../recipe"; +import type { Recipe, RecipeDuration, RecipeIngredient, RecipeIngredientSection, RecipeInstruction, RecipeMeal } from "../recipe"; export interface GetRecipeOfTheWeekResponse { status: number; @@ -17,3 +17,22 @@ export interface SearchRecipesResponse { message: string; recipes?: Recipe[]; } + +export interface CreateRecipeResponse { + status: number; + message: string; + recipe?: Recipe; +} + +export interface CreateRecipeRequest { + Title: string; + Description: string; + Instructions: RecipeInstruction[]; + Serves: number; + Difficulty: number; + Duration: RecipeDuration; + Category: RecipeMeal; + Ingredients: RecipeIngredient[]; + Sections: RecipeIngredientSection[]; + Tags: string[]; +} diff --git a/web/src/types/recipe.ts b/web/src/types/recipe.ts index 9e30522..080991a 100644 --- a/web/src/types/recipe.ts +++ b/web/src/types/recipe.ts @@ -81,12 +81,13 @@ export interface Recipe { Id: number; Title: string; Description: string; - Instructions: string[]; + Instructions: RecipeInstruction[]; Serves: number; Difficulty: number; Duration: RecipeDuration; Category: RecipeMeal; Ingredients: RecipeIngredient[]; + Sections: RecipeIngredientSection[]; UserId: number; Modified: Date; Created: Date;