Merge pull request '(FEAT): Recipe editing is complete!' (#87) from feature/edit-recipe into master
Some checks failed
Deploy application with Docker / build_and_deploy (push) Failing after 43s
Some checks failed
Deploy application with Docker / build_and_deploy (push) Failing after 43s
Reviewed-on: #87
This commit is contained in:
commit
1e88f075cb
@ -131,6 +131,37 @@ func (s *Server) CreateRecipeHandlerV2(ctx *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) EditRecipeHandlerV2(ctx *gin.Context) {
|
||||||
|
s.withAuthenticatedUser(ctx, func(ctx *gin.Context, user *domainUser.User) {
|
||||||
|
id := ctx.Param("id")
|
||||||
|
parsedId, err := strconv.Atoi(id)
|
||||||
|
if err != nil {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"status": http.StatusBadRequest,
|
||||||
|
"message": fmt.Sprintf("[ERROR] Failed to parse ID parameter. %s", err.Error()),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
recipe, err := s.deps.RecipeService.EditRecipe(ctx, parsedId, user.Id)
|
||||||
|
|
||||||
|
_, err = s.deps.EngagementService.UserEditRecipe(user.Id, recipe.Id)
|
||||||
|
if err != nil {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"status": http.StatusBadRequest,
|
||||||
|
"message": fmt.Sprintf("[ERROR] Failed to create recipe engagement. %s", err.Error()),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, gin.H{
|
||||||
|
"status": http.StatusOK,
|
||||||
|
"message": "[OK] Successfully updated recipe.",
|
||||||
|
"recipe": recipe,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) DeleteRecipeHandlerV2(ctx *gin.Context) {
|
func (s *Server) DeleteRecipeHandlerV2(ctx *gin.Context) {
|
||||||
s.withAuthenticatedUser(ctx, func(ctx *gin.Context, user *domainUser.User) {
|
s.withAuthenticatedUser(ctx, func(ctx *gin.Context, user *domainUser.User) {
|
||||||
id := ctx.Param("id")
|
id := ctx.Param("id")
|
||||||
|
|||||||
@ -259,10 +259,11 @@ func (s *Server) Setup() *Server {
|
|||||||
|
|
||||||
// ---- VERSION 2 ROUTES ---- //
|
// ---- VERSION 2 ROUTES ---- //
|
||||||
router_api_v2 := router_v2.Group(domain.API)
|
router_api_v2 := router_v2.Group(domain.API)
|
||||||
|
router_api_v2.POST("/recipe", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.CreateRecipeHandlerV2)
|
||||||
router_api_v2.GET("/recipe/:id", JwtOptionalAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.GetRecipeHandlerV2)
|
router_api_v2.GET("/recipe/:id", JwtOptionalAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.GetRecipeHandlerV2)
|
||||||
|
router_api_v2.PUT("/recipe/:id", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.EditRecipeHandlerV2)
|
||||||
router_api_v2.GET("/recipe/of-the-week", JwtOptionalAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.GetRecipeOfTheWeekHandlerV2)
|
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/search", JwtOptionalAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.SearchRecipeHandlerV2)
|
||||||
router_api_v2.POST("/recipe", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.CreateRecipeHandlerV2)
|
|
||||||
router_api_v2.DELETE("/recipe/:id", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.DeleteRecipeHandlerV2)
|
router_api_v2.DELETE("/recipe/:id", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.DeleteRecipeHandlerV2)
|
||||||
router_api_v2.GET("/recipe/:id/is-owner", JwtOptionalAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.IsRecipeOwnerV2)
|
router_api_v2.GET("/recipe/:id/is-owner", JwtOptionalAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.IsRecipeOwnerV2)
|
||||||
|
|
||||||
|
|||||||
@ -150,6 +150,20 @@ func (s *EngagementService) UserDeleteRecipe(userId, recipeId int) (domain.Engag
|
|||||||
return s.engagementRepository.AddUserEntityEngagement(userId, recipeId, message, domain.EngagementDeleted)
|
return s.engagementRepository.AddUserEntityEngagement(userId, recipeId, message, domain.EngagementDeleted)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UserEditRecipe requires a user ID and a recipe ID to create an engagement record in the database.
|
||||||
|
// A message will be generated using the recipe data and then used to add a make engagement to the
|
||||||
|
// database.
|
||||||
|
func (s *EngagementService) UserEditRecipe(userId, recipeId int) (domain.Engagement, error) {
|
||||||
|
recipe, err := s.recipeRepository.GetRecipe(recipeId, &userId)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Engagement{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
message := fmt.Sprintf("Edited \"%s\"", recipe.Title)
|
||||||
|
|
||||||
|
return s.engagementRepository.AddUserEntityEngagement(userId, recipeId, message, domain.EngagementEdited)
|
||||||
|
}
|
||||||
|
|
||||||
// GetUserEngagement returns a list of the users most recent engagement entries. The number of records
|
// GetUserEngagement returns a list of the users most recent engagement entries. The number of records
|
||||||
// is determined by the limit passed into this function. The results are sorted, newest-to-oldest.
|
// is determined by the limit passed into this function. The results are sorted, newest-to-oldest.
|
||||||
func (s *EngagementService) GetUserEngagement(userId, limit int) ([]domain.Engagement, error) {
|
func (s *EngagementService) GetUserEngagement(userId, limit int) ([]domain.Engagement, error) {
|
||||||
|
|||||||
@ -80,6 +80,44 @@ func (s *RecipeService) CreateRecipe(ctx *gin.Context) (*domain.Recipe, error) {
|
|||||||
return &recipe, nil
|
return &recipe, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *RecipeService) EditRecipe(ctx *gin.Context, recipeId, userId int) (*domain.Recipe, error) {
|
||||||
|
var req domain.EditRecipeRequest
|
||||||
|
|
||||||
|
if err := ctx.ShouldBindJSON(&req); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if recipeId != req.Id {
|
||||||
|
return nil, fmt.Errorf("[ERROR] Mismatched recipe IDs provided. Given %d and %d.", recipeId, req.Id)
|
||||||
|
}
|
||||||
|
|
||||||
|
recipe := domain.Recipe{
|
||||||
|
Id: recipeId,
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.recipeRepository.EditRecipe(&recipe, userId); err != nil {
|
||||||
|
return &recipe, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the tags
|
||||||
|
if len(req.Tags) > 0 {
|
||||||
|
if err := s.recipeRepository.UpdateRecipeTags(recipe, req.Tags); err != nil {
|
||||||
|
return &recipe, fmt.Errorf("Failed to attach/create tags. %s\n", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &recipe, nil
|
||||||
|
}
|
||||||
|
|
||||||
// DeleteRecipe deletes a recipe in the database using the recipe repository. This function requires
|
// DeleteRecipe deletes a recipe in the database using the recipe repository. This function requires
|
||||||
// the userId of the requesting user - to ensure the user is the owner of the recipe. Any errors will
|
// the userId of the requesting user - to ensure the user is the owner of the recipe. Any errors will
|
||||||
// be returned to caller when/if they occur.
|
// be returned to caller when/if they occur.
|
||||||
|
|||||||
@ -16,6 +16,7 @@ const (
|
|||||||
EngagementRated EngagementType = "rated"
|
EngagementRated EngagementType = "rated"
|
||||||
EngagementCreated EngagementType = "created"
|
EngagementCreated EngagementType = "created"
|
||||||
EngagementDeleted EngagementType = "deleted"
|
EngagementDeleted EngagementType = "deleted"
|
||||||
|
EngagementEdited EngagementType = "edited"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Engagement is the database model of a user engagement. There is no need to map to a different
|
// Engagement is the database model of a user engagement. There is no need to map to a different
|
||||||
|
|||||||
@ -9,5 +9,6 @@ type EngagementService interface {
|
|||||||
UserShareRecipe(userId, recipeId int) (Engagement, error)
|
UserShareRecipe(userId, recipeId int) (Engagement, error)
|
||||||
UserCreateRecipe(userId, recipeId int) (Engagement, error)
|
UserCreateRecipe(userId, recipeId int) (Engagement, error)
|
||||||
UserDeleteRecipe(userId, recipeId int) (Engagement, error)
|
UserDeleteRecipe(userId, recipeId int) (Engagement, error)
|
||||||
|
UserEditRecipe(userId, recipeId int) (Engagement, error)
|
||||||
GetUserEngagement(userId, limit int) ([]Engagement, error)
|
GetUserEngagement(userId, limit int) ([]Engagement, error)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -156,7 +156,7 @@ type RecipeTag struct {
|
|||||||
Created time.Time
|
Created time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Comment
|
// TODO: Document
|
||||||
type CreateRecipeRequest struct {
|
type CreateRecipeRequest struct {
|
||||||
Title string
|
Title string
|
||||||
Description string
|
Description string
|
||||||
@ -169,3 +169,18 @@ type CreateRecipeRequest struct {
|
|||||||
Sections []RecipeIngredientSection
|
Sections []RecipeIngredientSection
|
||||||
Tags []string
|
Tags []string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO Document
|
||||||
|
type EditRecipeRequest struct {
|
||||||
|
Id int
|
||||||
|
Title string
|
||||||
|
Description string
|
||||||
|
Instructions []RecipeInstruction
|
||||||
|
Serves int
|
||||||
|
Difficulty int
|
||||||
|
Duration RecipeDuration
|
||||||
|
Category RecipeMeal
|
||||||
|
Ingredients []RecipeIngredient
|
||||||
|
Sections []RecipeIngredientSection
|
||||||
|
Tags []string
|
||||||
|
}
|
||||||
|
|||||||
@ -2,11 +2,13 @@ package domain
|
|||||||
|
|
||||||
type RecipeRepository interface {
|
type RecipeRepository interface {
|
||||||
CreateRecipe(recipe *Recipe) error
|
CreateRecipe(recipe *Recipe) error
|
||||||
|
EditRecipe(recipe *Recipe, userId int) error
|
||||||
DeleteRecipe(recipeId int) error
|
DeleteRecipe(recipeId int) error
|
||||||
GetRecipe(id int, userId *int) (*Recipe, error)
|
GetRecipe(id int, userId *int) (*Recipe, error)
|
||||||
GetRecipes(ids []int, userId *int) ([]Recipe, error)
|
GetRecipes(ids []int, userId *int) ([]Recipe, error)
|
||||||
SearchRecipes(filters SearchFilters, userId *int, favorites bool) ([]int, error)
|
SearchRecipes(filters SearchFilters, userId *int, favorites bool) ([]int, error)
|
||||||
CreateRecipeTags(recipe Recipe, tags []string) error
|
CreateRecipeTags(recipe Recipe, tags []string) error
|
||||||
|
UpdateRecipeTags(recipe Recipe, tags []string) error
|
||||||
GetUserRecipesIds(userId int) ([]int, error)
|
GetUserRecipesIds(userId int) ([]int, error)
|
||||||
GetUserFavoriteRecipesIds(userId int) ([]int, error)
|
GetUserFavoriteRecipesIds(userId int) ([]int, error)
|
||||||
GetRecipeTags(recipe *Recipe) error
|
GetRecipeTags(recipe *Recipe) error
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import (
|
|||||||
|
|
||||||
type RecipeService interface {
|
type RecipeService interface {
|
||||||
CreateRecipe(ctx *gin.Context) (*Recipe, error)
|
CreateRecipe(ctx *gin.Context) (*Recipe, error)
|
||||||
|
EditRecipe(ctx *gin.Context, recipeId, userId int) (*Recipe, error)
|
||||||
DeleteRecipe(userId, recipeId int) error
|
DeleteRecipe(userId, recipeId int) error
|
||||||
GetRecipe(id int, userId *int) (*Recipe, error)
|
GetRecipe(id int, userId *int) (*Recipe, error)
|
||||||
SearchRecipes(filters SearchFilters, userId *int, favorites bool) ([]Recipe, error)
|
SearchRecipes(filters SearchFilters, userId *int, favorites bool) ([]Recipe, error)
|
||||||
|
|||||||
@ -0,0 +1,10 @@
|
|||||||
|
-- Author: Hayden Hargreaves (hhargreaves2006@gmail.com)
|
||||||
|
-- Desc: Updated the E_ENGAGEMENT enum to contain edited.
|
||||||
|
-- Date: 02/01/2026
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TYPE E_ENGAGEMENT
|
||||||
|
ADD VALUE IF NOT EXISTS 'edited'; -- edited recipe
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@ -14,4 +14,5 @@ psql -h "$PSQL_HOST" -U "$PSQL_USERNAME" -d "$PSQL_DATABASE" -f ./internal/infra
|
|||||||
psql -h "$PSQL_HOST" -U "$PSQL_USERNAME" -d "$PSQL_DATABASE" -f ./internal/infrastructure/database/migrations/012_update_recipes_table_deleted_column.sql
|
psql -h "$PSQL_HOST" -U "$PSQL_USERNAME" -d "$PSQL_DATABASE" -f ./internal/infrastructure/database/migrations/012_update_recipes_table_deleted_column.sql
|
||||||
psql -h "$PSQL_HOST" -U "$PSQL_USERNAME" -d "$PSQL_DATABASE" -f ./internal/infrastructure/database/migrations/013_update_recipes_allow_large_servings.sql
|
psql -h "$PSQL_HOST" -U "$PSQL_USERNAME" -d "$PSQL_DATABASE" -f ./internal/infrastructure/database/migrations/013_update_recipes_allow_large_servings.sql
|
||||||
psql -h "$PSQL_HOST" -U "$PSQL_USERNAME" -d "$PSQL_DATABASE" -f ./internal/infrastructure/database/migrations/014_create_logs_table.sql
|
psql -h "$PSQL_HOST" -U "$PSQL_USERNAME" -d "$PSQL_DATABASE" -f ./internal/infrastructure/database/migrations/014_create_logs_table.sql
|
||||||
|
psql -h "$PSQL_HOST" -U "$PSQL_USERNAME" -d "$PSQL_DATABASE" -f ./internal/infrastructure/database/migrations/015_update_engagement_enum.sql
|
||||||
|
|
||||||
|
|||||||
@ -31,7 +31,6 @@ func NewEngagementRepository(db *sql.DB) domain.EngagementRepository {
|
|||||||
func (r *EngagementRepository) AddUserEngagement(userId int, message string, engagementType domain.EngagementType) (domain.Engagement, error) {
|
func (r *EngagementRepository) AddUserEngagement(userId int, message string, engagementType domain.EngagementType) (domain.Engagement, error) {
|
||||||
tx, err := r.db.Begin()
|
tx, err := r.db.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
tx.Rollback()
|
|
||||||
return domain.Engagement{}, err
|
return domain.Engagement{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -58,7 +57,6 @@ func (r *EngagementRepository) AddUserEngagement(userId int, message string, eng
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
tx.Rollback()
|
|
||||||
return domain.Engagement{}, err
|
return domain.Engagement{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -80,7 +78,6 @@ func (r *EngagementRepository) AddUserEngagement(userId int, message string, eng
|
|||||||
func (r *EngagementRepository) AddUserEntityEngagement(userId, entityId int, message string, engagementType domain.EngagementType) (domain.Engagement, error) {
|
func (r *EngagementRepository) AddUserEntityEngagement(userId, entityId int, message string, engagementType domain.EngagementType) (domain.Engagement, error) {
|
||||||
tx, err := r.db.Begin()
|
tx, err := r.db.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
tx.Rollback()
|
|
||||||
return domain.Engagement{}, err
|
return domain.Engagement{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -107,7 +104,6 @@ func (r *EngagementRepository) AddUserEntityEngagement(userId, entityId int, mes
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
tx.Rollback()
|
|
||||||
return domain.Engagement{}, err
|
return domain.Engagement{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -141,7 +137,6 @@ func (r *EngagementRepository) AddEngagement(message string, engagementType doma
|
|||||||
|
|
||||||
tx, err := r.db.Begin()
|
tx, err := r.db.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
tx.Rollback()
|
|
||||||
return domain.Engagement{}, err
|
return domain.Engagement{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -168,7 +163,6 @@ func (r *EngagementRepository) AddEngagement(message string, engagementType doma
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
tx.Rollback()
|
|
||||||
return domain.Engagement{}, err
|
return domain.Engagement{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -202,7 +196,6 @@ func (r *EngagementRepository) AddEntityEngagement(entityId int, message string,
|
|||||||
|
|
||||||
tx, err := r.db.Begin()
|
tx, err := r.db.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
tx.Rollback()
|
|
||||||
return domain.Engagement{}, err
|
return domain.Engagement{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -229,7 +222,6 @@ func (r *EngagementRepository) AddEntityEngagement(entityId int, message string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
tx.Rollback()
|
|
||||||
return domain.Engagement{}, err
|
return domain.Engagement{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -339,7 +331,6 @@ func (r *EngagementRepository) GetUserEngagementFiltered(userId, limit int, enga
|
|||||||
func (r *EngagementRepository) UserFavoriteRecipeToggle(userId, recipeId int) (bool, error) {
|
func (r *EngagementRepository) UserFavoriteRecipeToggle(userId, recipeId int) (bool, error) {
|
||||||
tx, err := r.db.Begin()
|
tx, err := r.db.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
tx.Rollback()
|
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
domain "github.com/haydenhargreaves/Potion/internal/domain/recipe"
|
domain "github.com/haydenhargreaves/Potion/internal/domain/recipe"
|
||||||
"github.com/lib/pq"
|
"github.com/lib/pq"
|
||||||
@ -33,7 +34,6 @@ func NewRecipeRepository(db *sql.DB) domain.RecipeRepository {
|
|||||||
func (r *RecipeRepository) CreateRecipe(recipe *domain.Recipe) error {
|
func (r *RecipeRepository) CreateRecipe(recipe *domain.Recipe) error {
|
||||||
tx, err := r.db.Begin()
|
tx, err := r.db.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
tx.Rollback()
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -92,7 +92,6 @@ func (r *RecipeRepository) CreateRecipe(recipe *domain.Recipe) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
tx.Rollback()
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -102,6 +101,97 @@ func (r *RecipeRepository) CreateRecipe(recipe *domain.Recipe) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EditRecipe updates a recipe in the database. The recipe provided must contain an ID, otherwise this
|
||||||
|
// function will fail - it will not know what recipe to edit.
|
||||||
|
func (r *RecipeRepository) EditRecipe(recipe *domain.Recipe, userId int) error {
|
||||||
|
if recipe.Id <= 0 {
|
||||||
|
return fmt.Errorf("[ERROR] Recipe must contain an ID. Cannot edit unknown recipe.")
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := r.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// This query will ensure the userId matches the owner.
|
||||||
|
query := `UPDATE recipes SET
|
||||||
|
title = $1,
|
||||||
|
description = $2,
|
||||||
|
instructions = $3,
|
||||||
|
serves = $4,
|
||||||
|
difficulty = $5,
|
||||||
|
duration = $6,
|
||||||
|
category = $7,
|
||||||
|
ingredients = $8,
|
||||||
|
modified = $9
|
||||||
|
WHERE id = $10
|
||||||
|
AND userid = $11;`
|
||||||
|
|
||||||
|
// NOTE: Data steps
|
||||||
|
// cast duration 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
|
||||||
|
|
||||||
|
durationJSON, err := json.Marshal(recipe.Duration)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := tx.Exec(
|
||||||
|
query,
|
||||||
|
recipe.Title,
|
||||||
|
recipe.Description,
|
||||||
|
pq.Array(instructions),
|
||||||
|
recipe.Serves,
|
||||||
|
recipe.Difficulty,
|
||||||
|
durationJSON,
|
||||||
|
string(recipe.Category),
|
||||||
|
ingredientsJSON,
|
||||||
|
time.Now().UTC(),
|
||||||
|
recipe.Id,
|
||||||
|
userId,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if rows != 1 {
|
||||||
|
tx.Rollback()
|
||||||
|
return fmt.Errorf("[ERROR] Modified an unexpected number of rows. Expected 1, modified %d.", rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// DeleteRecipe deletes a recipe in the database. This is done by setting the deleted field to true.
|
// DeleteRecipe deletes a recipe in the database. This is done by setting the deleted field to true.
|
||||||
// This will create a "soft delete" effect. This function does not validate that the user is the owner,
|
// This will create a "soft delete" effect. This function does not validate that the user is the owner,
|
||||||
// so the caller should validate the owner. If any errors occur, they will be returned to the caller.
|
// so the caller should validate the owner. If any errors occur, they will be returned to the caller.
|
||||||
@ -437,7 +527,6 @@ func (r *RecipeRepository) SearchRecipes(filters domain.SearchFilters, userId *i
|
|||||||
func (r *RecipeRepository) CreateRecipeTags(recipe domain.Recipe, tags []string) error {
|
func (r *RecipeRepository) CreateRecipeTags(recipe domain.Recipe, tags []string) error {
|
||||||
tx, err := r.db.Begin()
|
tx, err := r.db.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
tx.Rollback()
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -487,6 +576,82 @@ func (r *RecipeRepository) CreateRecipeTags(recipe domain.Recipe, tags []string)
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateRecipeTags replaces all existing tags for a recipe with a new list of tags.
|
||||||
|
// It removes all current tag associations, creates any new tags that don't exist,
|
||||||
|
// and creates new associations for the provided tags. The recipe object must contain
|
||||||
|
// a valid ID. Any errors will be bubbled to the caller.
|
||||||
|
func (r *RecipeRepository) UpdateRecipeTags(recipe domain.Recipe, tags []string) error {
|
||||||
|
tx, err := r.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback() // Rollback if we don't commit
|
||||||
|
|
||||||
|
if recipe.Id <= 0 {
|
||||||
|
return fmt.Errorf("[ERROR] Recipe must have a valid ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: Delete all existing tag associations for this recipe
|
||||||
|
deleteQuery := `DELETE FROM RecipeTags WHERE RecipeId = $1;`
|
||||||
|
if _, err := tx.Exec(deleteQuery, recipe.Id); err != nil {
|
||||||
|
return fmt.Errorf("[ERROR] Failed to delete existing recipe tags: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Normalize the tag names (lower case with trimmed space)
|
||||||
|
normalized := make(map[string]struct{}) // Use map to disallow duplicates
|
||||||
|
for _, tag := range tags {
|
||||||
|
trimmed := strings.ToLower(strings.TrimSpace(tag))
|
||||||
|
if trimmed != "" {
|
||||||
|
normalized[trimmed] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no tags provided, we're done (all tags removed)
|
||||||
|
if len(normalized) == 0 {
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Insert the tags into the DB and return their IDs into the tag ID list
|
||||||
|
var tagIds []int
|
||||||
|
for tag := range normalized {
|
||||||
|
var tagId int
|
||||||
|
query := `
|
||||||
|
INSERT INTO tags (name) VALUES ($1)
|
||||||
|
ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name
|
||||||
|
RETURNING id;
|
||||||
|
`
|
||||||
|
err := tx.QueryRow(query, tag).Scan(&tagId)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("[ERROR] Failed to retrieve or create tag: %w", err)
|
||||||
|
}
|
||||||
|
tagIds = append(tagIds, tagId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Insert the new tag associations
|
||||||
|
// Use a single prepared statement for all inserts
|
||||||
|
stmt, err := tx.Prepare("INSERT INTO RecipeTags (RecipeId, TagId) VALUES ($1, $2);")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("[ERROR] Failed to create statement for recipe tag mapping: %w", err)
|
||||||
|
}
|
||||||
|
defer stmt.Close()
|
||||||
|
|
||||||
|
for _, id := range tagIds {
|
||||||
|
if _, err := stmt.Exec(recipe.Id, id); err != nil {
|
||||||
|
return fmt.Errorf("[ERROR] Failed to insert tag-recipe mapping: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commit the transaction
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetUserRecipes gets a list of a users owned recipes. This function does not ensure the user is
|
// 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
|
// 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.
|
// is sorted by the created dates, newest first. Any errors will be bubbled to the caller.
|
||||||
|
|||||||
@ -32,7 +32,6 @@ func NewUserRepository(db *sql.DB) domain.UserRepository {
|
|||||||
func (r *UserRepository) CreateGoogleUser(googleUserInfo *domain.GoogleUserInfo, googleRefreshToken string) (domain.User, error) {
|
func (r *UserRepository) CreateGoogleUser(googleUserInfo *domain.GoogleUserInfo, googleRefreshToken string) (domain.User, error) {
|
||||||
tx, err := r.db.Begin()
|
tx, err := r.db.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
tx.Rollback()
|
|
||||||
return domain.User{}, err
|
return domain.User{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -66,7 +65,6 @@ func (r *UserRepository) CreateGoogleUser(googleUserInfo *domain.GoogleUserInfo,
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
tx.Rollback()
|
|
||||||
return domain.User{}, err
|
return domain.User{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -6,7 +6,7 @@ interface DeleteButtonProps {
|
|||||||
|
|
||||||
export default function DeleteButton({ clickHandler }: DeleteButtonProps) {
|
export default function DeleteButton({ clickHandler }: DeleteButtonProps) {
|
||||||
return (
|
return (
|
||||||
<button onClick={clickHandler} className="flex items-center justify-center gap-x-1 rounded-lg border border-gray-300 text-gray-800 px-6 py-3 flex-grow hover:bg-gray-50 hover:border-red-300 duration-300 cursor-pointer">
|
<button onClick={clickHandler} className="flex items-center min-w-1/4 justify-center gap-x-1 rounded-lg border border-gray-300 text-gray-800 px-6 py-3 flex-grow hover:bg-gray-50 hover:border-red-300 duration-300 cursor-pointer">
|
||||||
<DeleteIconSmall />
|
<DeleteIconSmall />
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
15
web/src/components/buttons/EditButton.tsx
Normal file
15
web/src/components/buttons/EditButton.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import EditIconSmall from "../icons/EditIconSmall";
|
||||||
|
|
||||||
|
interface EditButtonProps {
|
||||||
|
clickHandler: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EditButton({ clickHandler }: EditButtonProps) {
|
||||||
|
return (
|
||||||
|
<button onClick={clickHandler} className="flex items-center min-w-1/4hh justify-center gap-x-1 rounded-lg border border-gray-300 text-gray-800 px-6 py-3 flex-grow hover:bg-gray-50 hover:border-blue-300 duration-300 cursor-pointer">
|
||||||
|
<EditIconSmall />
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
}
|
||||||
@ -32,7 +32,7 @@ export default function FavoriteButton({ favorite, id }: FavoriteButtonProps) {
|
|||||||
const result = await EngagementFavoriteRecipe(id);
|
const result = await EngagementFavoriteRecipe(id);
|
||||||
if (isApiError(result)) {
|
if (isApiError(result)) {
|
||||||
console.error(result.message);
|
console.error(result.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -43,7 +43,7 @@ export default function FavoriteButton({ favorite, id }: FavoriteButtonProps) {
|
|||||||
|
|
||||||
return _favorite ? (
|
return _favorite ? (
|
||||||
<button
|
<button
|
||||||
className="flex items-center justify-center gap-x-1 rounded-lg border border-blue-300 bg-blue-50 text-gray-800 px-6 py-3 flex-grow hover:bg-blue-100 hover:border-blue-500 duration-300 cursor-pointer"
|
className="flex items-center min-w-1/4 justify-center gap-x-1 rounded-lg border border-blue-300 bg-blue-50 text-gray-800 px-6 py-3 flex-grow hover:bg-blue-100 hover:border-blue-500 duration-300 cursor-pointer"
|
||||||
onClick={() => void clickHandler()}
|
onClick={() => void clickHandler()}
|
||||||
>
|
>
|
||||||
<svg className="h-6 text-red-500" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg className="h-6 text-red-500" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
|||||||
@ -30,12 +30,12 @@ export default function MadeButton({ id }: MadeButtonProps) {
|
|||||||
const result = await EngagementMakeRecipe(id);
|
const result = await EngagementMakeRecipe(id);
|
||||||
if (isApiError(result)) {
|
if (isApiError(result)) {
|
||||||
console.error(result.message);
|
console.error(result.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={`flex items-center justify-center gap-x-1 rounded-lg border ${clicked ? "border-blue-500 text-blue-500" : "border-gray-300 text-gray-800"} px-6 py-3 flex-grow hover:bg-gray-50 hover:border-blue-300 duration-300 cursor-pointer`}
|
className={`flex items-center min-w-1/4 justify-center gap-x-1 rounded-lg border ${clicked ? "border-blue-500 text-blue-500" : "border-gray-300 text-gray-800"} px-6 py-3 flex-grow hover:bg-gray-50 hover:border-blue-300 duration-300 cursor-pointer`}
|
||||||
onClick={() => void clickHandler()}
|
onClick={() => void clickHandler()}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
|
|||||||
@ -29,7 +29,7 @@ export default function ShareButton({ id }: ShareButtonProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return clicked ? (
|
return clicked ? (
|
||||||
<button className="flex items-center justify-center gap-x-1 rounded-lg border border-green-500 text-green-500 px-6 py-3 flex-grow hover:bg-gray-50 hover:border-blue-300 duration-300">
|
<button className="flex items-center min-w-1/4 justify-center gap-x-1 rounded-lg border border-green-500 text-green-500 px-6 py-3 flex-grow hover:bg-gray-50 hover:border-blue-300 duration-300">
|
||||||
<svg className="h-7" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg className="h-7" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path
|
<path
|
||||||
fillRule="evenodd"
|
fillRule="evenodd"
|
||||||
@ -41,7 +41,7 @@ export default function ShareButton({ id }: ShareButtonProps) {
|
|||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
className="flex items-center justify-center gap-x-1 rounded-lg border border-gray-300 text-gray-800 px-6 py-3 flex-grow hover:bg-gray-50 hover:border-blue-300 duration-300 cursor-pointer"
|
className="flex items-center min-w-1/4 justify-center gap-x-1 rounded-lg border border-gray-300 text-gray-800 px-6 py-3 flex-grow hover:bg-gray-50 hover:border-blue-300 duration-300 cursor-pointer"
|
||||||
onClick={() => void clickHandler()}
|
onClick={() => void clickHandler()}
|
||||||
>
|
>
|
||||||
<svg className="h-7" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg className="h-7" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { Reorder } from "motion/react";
|
import { Reorder } from "motion/react";
|
||||||
import InstructionElement from "./InstructionElement";
|
import InstructionElement from "./InstructionElement";
|
||||||
import type { Dispatch, SetStateAction } from "react";
|
import { useEffect, type Dispatch, type SetStateAction } from "react";
|
||||||
import type { RecipeInstruction } from "../../types/recipe";
|
import type { RecipeInstruction } from "../../types/recipe";
|
||||||
import type { RecipeValidationEntry } from "../../pages/Create";
|
import type { RecipeValidationEntry } from "../../pages/Create";
|
||||||
|
|
||||||
|
|||||||
7
web/src/components/icons/EditIconSmall.tsx
Normal file
7
web/src/components/icons/EditIconSmall.tsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
export default function EditIconSmall() {
|
||||||
|
return (
|
||||||
|
<svg className="size-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640">
|
||||||
|
<path d="M535.6 85.7C513.7 63.8 478.3 63.8 456.4 85.7L432 110.1L529.9 208L554.3 183.6C576.2 161.7 576.2 126.3 554.3 104.4L535.6 85.7zM236.4 305.7C230.3 311.8 225.6 319.3 222.9 327.6L193.3 416.4C190.4 425 192.7 434.5 199.1 441C205.5 447.5 215 449.7 223.7 446.8L312.5 417.2C320.7 414.5 328.2 409.8 334.4 403.7L496 241.9L398.1 144L236.4 305.7zM160 128C107 128 64 171 64 224L64 480C64 533 107 576 160 576L416 576C469 576 512 533 512 480L512 384C512 366.3 497.7 352 480 352C462.3 352 448 366.3 448 384L448 480C448 497.7 433.7 512 416 512L160 512C142.3 512 128 497.7 128 480L128 224C128 206.3 142.3 192 160 192L256 192C273.7 192 288 177.7 288 160C288 142.3 273.7 128 256 128L160 128z" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -46,7 +46,7 @@ export function useIngredients() {
|
|||||||
const ingredientChange = (id: string, name: "Amount" | "Unit" | "Name", value: string) => {
|
const ingredientChange = (id: string, name: "Amount" | "Unit" | "Name", value: string) => {
|
||||||
setIngredients(prev =>
|
setIngredients(prev =>
|
||||||
prev.map(ing =>
|
prev.map(ing =>
|
||||||
ing.Id === id
|
ing.Id === id
|
||||||
? { ...ing, [name]: name === "Amount" ? Number(value) : value }
|
? { ...ing, [name]: name === "Amount" ? Number(value) : value }
|
||||||
: ing
|
: ing
|
||||||
)
|
)
|
||||||
@ -72,6 +72,7 @@ export function useIngredients() {
|
|||||||
return {
|
return {
|
||||||
sections,
|
sections,
|
||||||
ingredients,
|
ingredients,
|
||||||
|
setIngredients,
|
||||||
setSections,
|
setSections,
|
||||||
sectionChange,
|
sectionChange,
|
||||||
ingredientChange,
|
ingredientChange,
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import Banner from "../components/Banner";
|
import Banner from "../components/Banner";
|
||||||
import { isRecipeMeal, type RecipeInstruction } from "../types/recipe";
|
import { isRecipeMeal, type Recipe, type RecipeInstruction } from "../types/recipe";
|
||||||
import InstructionList from "../components/forms/InstructionList";
|
import InstructionList from "../components/forms/InstructionList";
|
||||||
import ValidationErrorList from "../components/forms/ValidationErrorList";
|
import ValidationErrorList from "../components/forms/ValidationErrorList";
|
||||||
import IngredientSection from "../components/forms/IngredientSection";
|
import IngredientSection from "../components/forms/IngredientSection";
|
||||||
@ -13,10 +13,10 @@ import RecipeCreateFormWrapper from "../components/inputs/RecipeCreateFormWrappe
|
|||||||
import RecipeCreateFormTagsInputs from "../components/inputs/RecipeCreateFormTagsInput";
|
import RecipeCreateFormTagsInputs from "../components/inputs/RecipeCreateFormTagsInput";
|
||||||
import { useIngredients } from "../hooks/useIngredients";
|
import { useIngredients } from "../hooks/useIngredients";
|
||||||
import { validateCreateRecipeForm } from "../hooks/validation";
|
import { validateCreateRecipeForm } from "../hooks/validation";
|
||||||
import { CreateRecipe } from "../services/RecipeService";
|
import { CreateRecipe, EditRecipe, GetRecipe, IsRecipeOwner } from "../services/RecipeService";
|
||||||
import type { CreateRecipeRequest } from "../types/api/recipe";
|
import type { CreateRecipeRequest, EditRecipeRequest } from "../types/api/recipe";
|
||||||
import { isApiError } from "../types/api/error";
|
import { isApiError, type ApiError } from "../types/api/error";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import ROUTE_CONSTANTS from "../types/routes";
|
import ROUTE_CONSTANTS from "../types/routes";
|
||||||
|
|
||||||
// TODO: Move these
|
// TODO: Move these
|
||||||
@ -128,6 +128,7 @@ export default function Create() {
|
|||||||
// Functions
|
// Functions
|
||||||
const createRecipe = async (): Promise<void> => {
|
const createRecipe = async (): Promise<void> => {
|
||||||
// Exit if not valid recipe meal
|
// Exit if not valid recipe meal
|
||||||
|
// This is a REQUIRED typescript check.
|
||||||
if (!isRecipeMeal(category)) {
|
if (!isRecipeMeal(category)) {
|
||||||
console.error("[ERROR] Recipe meal is invalid.");
|
console.error("[ERROR] Recipe meal is invalid.");
|
||||||
return;
|
return;
|
||||||
@ -160,10 +161,53 @@ export default function Create() {
|
|||||||
await navigate(ROUTE_CONSTANTS.Recipe(response.Id));
|
await navigate(ROUTE_CONSTANTS.Recipe(response.Id));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const editRecipe = async (): Promise<void> => {
|
||||||
|
const recipeId = Number(editingId);
|
||||||
|
|
||||||
|
if (!recipeId) {
|
||||||
|
console.error("[ERROR] Invalid reicpe ID");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exit if not valid recipe meal
|
||||||
|
// This is a REQUIRED typescript check.
|
||||||
|
if (!isRecipeMeal(category)) {
|
||||||
|
console.error("[ERROR] Recipe meal is invalid.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipe: EditRecipeRequest = {
|
||||||
|
Id: recipeId,
|
||||||
|
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 EditRecipe(recipe);
|
||||||
|
if (isApiError(response)) {
|
||||||
|
console.error(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// TODO: Success toast!
|
||||||
|
await navigate(ROUTE_CONSTANTS.Recipe(response.Id));
|
||||||
|
}
|
||||||
|
|
||||||
// Import ingredients
|
// Import ingredients
|
||||||
const {
|
const {
|
||||||
sections,
|
sections,
|
||||||
ingredients,
|
ingredients,
|
||||||
|
setIngredients,
|
||||||
setSections,
|
setSections,
|
||||||
sectionChange,
|
sectionChange,
|
||||||
ingredientChange,
|
ingredientChange,
|
||||||
@ -244,7 +288,11 @@ export default function Create() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
void createRecipe();
|
if (editingId) {
|
||||||
|
void editRecipe();
|
||||||
|
} else {
|
||||||
|
void createRecipe();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -267,26 +315,115 @@ export default function Create() {
|
|||||||
setIsFormValid(bools_valid && ingredients_valid && instructions_valid);
|
setIsFormValid(bools_valid && ingredients_valid && instructions_valid);
|
||||||
}, [validation, dirty]);
|
}, [validation, dirty]);
|
||||||
|
|
||||||
useEffect(() => {
|
// EDITING IMPLEMENTATION
|
||||||
console.debug("@validation", validation);
|
const [searchParams] = useSearchParams();
|
||||||
}, [validation]);
|
const editingId = searchParams.get("edit");
|
||||||
|
|
||||||
|
// Functions
|
||||||
|
const getIsAuthor = async (recipeId: number) => {
|
||||||
|
if (!recipeId) return;
|
||||||
|
const response = await IsRecipeOwner(recipeId);
|
||||||
|
if (isApiError(response)) {
|
||||||
|
console.error(response.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const setForm = (recipe: Recipe) => {
|
||||||
|
setTitle(recipe.Title);
|
||||||
|
setDescription(recipe.Description);
|
||||||
|
setTags(recipe.Tags.map(tag => tag.Name));
|
||||||
|
setPrepTime(recipe.Duration.Prep.toString());
|
||||||
|
setCookTime(recipe.Duration.Cook.toString());
|
||||||
|
setServingSize(recipe.Serves.toString());
|
||||||
|
setCategory(recipe.Category);
|
||||||
|
setDifficulty(recipe.Difficulty.toString());
|
||||||
|
|
||||||
|
// Generate IDs for instructions and store them
|
||||||
|
const instructionsWithIds: RecipeInstruction[] = recipe.Instructions.map(ins => ({
|
||||||
|
Id: crypto.randomUUID(),
|
||||||
|
Content: ins.Content
|
||||||
|
}));
|
||||||
|
setInstructions(instructionsWithIds);
|
||||||
|
|
||||||
|
setSections(recipe.Sections);
|
||||||
|
|
||||||
|
// Manually set the local state, not ideal but it works
|
||||||
|
setIngredients(recipe.Ingredients.sort((a, b) => a.SectionId.localeCompare(b.SectionId)));
|
||||||
|
|
||||||
|
const ingredientsDirty: Record<string, boolean> = {};
|
||||||
|
recipe.Ingredients.forEach(ing => {
|
||||||
|
ingredientsDirty[ing.Id] = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const instructionsDirty: Record<string, boolean> = {};
|
||||||
|
instructionsWithIds.forEach(ins => {
|
||||||
|
instructionsDirty[ins.Id] = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
setDirty({
|
||||||
|
title: true,
|
||||||
|
description: true,
|
||||||
|
prepTime: true,
|
||||||
|
cookTime: true,
|
||||||
|
servingSize: true,
|
||||||
|
category: true,
|
||||||
|
difficulty: true,
|
||||||
|
ingredients: ingredientsDirty,
|
||||||
|
instructions: instructionsDirty,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Effects
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.debug("@dirty", dirty);
|
const id = Number(editingId);
|
||||||
}, [dirty]);
|
if (!id) return;
|
||||||
|
|
||||||
|
const execute = async () => {
|
||||||
|
const isAuthor = await getIsAuthor(id);
|
||||||
|
|
||||||
|
if (!isAuthor) {
|
||||||
|
console.error("User is not the owner, and cannot edit this recipe.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: Recipe | ApiError = await GetRecipe(id);
|
||||||
|
if (isApiError(result)) {
|
||||||
|
console.error(result.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setForm(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
void execute();
|
||||||
|
}, [editingId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Banner content="Create Your Masterpiece" />
|
<Banner content={editingId ? "Edit Your Recipe" : "Create Your Masterpiece"} />
|
||||||
<div className="mx-4 md:mx-16 my-8">
|
<div className="mx-4 md:mx-16 my-8">
|
||||||
<p className="mb-8">
|
<p className="mb-8">
|
||||||
Welcome to the Recipe Creation Wizard! Simply fill in the details about your culinary creation,
|
{editingId ? (
|
||||||
including the recipe's name, a description, and other specifics like its category, duration,
|
<>
|
||||||
and difficulty. Don't forget to dynamically add all your ingredients and instructions using
|
Welcome back! Update your recipe by modifying any of the details below, including the recipe's name,
|
||||||
the dedicated buttons, and feel free to upload an appealing image. All required fields are
|
description, category, duration, difficulty, ingredients, and instructions. You can add or remove
|
||||||
marked with an <span className="text-red-500">*</span>. Once everything looks perfect, just hit the "Create Recipe"
|
ingredients and instruction steps using the dedicated buttons, and update the recipe image if desired.
|
||||||
button to
|
All required fields are marked with an <span className="text-red-500">*</span>. Once you're happy
|
||||||
share your masterpiece!
|
with your changes, hit the "Update Recipe" button to save your edits!
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Welcome to the Recipe Creation Wizard! Simply fill in the details about your culinary creation,
|
||||||
|
including the recipe's name, a description, and other specifics like its category, duration,
|
||||||
|
and difficulty. Don't forget to dynamically add all your ingredients and instructions using
|
||||||
|
the dedicated buttons, and feel free to upload an appealing image. All required fields are
|
||||||
|
marked with an <span className="text-red-500">*</span>. Once everything looks perfect, just
|
||||||
|
hit the "Create Recipe" button to share your masterpiece!
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
<div>
|
<div>
|
||||||
{/* Title Input */}
|
{/* Title Input */}
|
||||||
@ -521,7 +658,7 @@ export default function Create() {
|
|||||||
disabled={!isFormValid}
|
disabled={!isFormValid}
|
||||||
className={`${isFormValid ? "bg-gradient-to-r from-blue-200 to-purple-200 cursor-pointer" : "bg-gray-200 text-gray-500 cursor-not-allowed"} w-full py-2 rounded-lg text-lg shadow-md`}
|
className={`${isFormValid ? "bg-gradient-to-r from-blue-200 to-purple-200 cursor-pointer" : "bg-gray-200 text-gray-500 cursor-not-allowed"} w-full py-2 rounded-lg text-lg shadow-md`}
|
||||||
>
|
>
|
||||||
Create Recipe
|
{editingId ? "Save Changes" : "Create Recipe"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import type { User } from "../types/user";
|
|||||||
import DeleteButton from "../components/buttons/DeleteButton";
|
import DeleteButton from "../components/buttons/DeleteButton";
|
||||||
import ROUTE_CONSTANTS from "../types/routes";
|
import ROUTE_CONSTANTS from "../types/routes";
|
||||||
import ConfirmRecipeDeleteModal from "../components/modals/ConfirmRecipeDeleteModal";
|
import ConfirmRecipeDeleteModal from "../components/modals/ConfirmRecipeDeleteModal";
|
||||||
|
import EditButton from "../components/buttons/EditButton";
|
||||||
|
|
||||||
export default function RecipePage() {
|
export default function RecipePage() {
|
||||||
// Url params
|
// Url params
|
||||||
@ -54,7 +55,7 @@ export default function RecipePage() {
|
|||||||
|
|
||||||
const getIsAuthor = async () => {
|
const getIsAuthor = async () => {
|
||||||
if (!recipe) return;
|
if (!recipe) return;
|
||||||
const response = await IsRecipeOwner(recipe.Id)
|
const response = await IsRecipeOwner(recipe.Id);
|
||||||
if (isApiError(response)) {
|
if (isApiError(response)) {
|
||||||
setError(response.message);
|
setError(response.message);
|
||||||
return;
|
return;
|
||||||
@ -80,6 +81,12 @@ export default function RecipePage() {
|
|||||||
setIsDeleting(true);
|
setIsDeleting(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const editHandler = () => {
|
||||||
|
if (!recipe || !isAuthor) return;
|
||||||
|
const route = ROUTE_CONSTANTS.Edit(Number(recipe.Id));
|
||||||
|
void navigate(route);
|
||||||
|
}
|
||||||
|
|
||||||
// Effects
|
// Effects
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void getRecipe(Number(id));
|
void getRecipe(Number(id));
|
||||||
@ -102,9 +109,9 @@ export default function RecipePage() {
|
|||||||
|
|
||||||
return recipe ? (
|
return recipe ? (
|
||||||
<>
|
<>
|
||||||
{isDeleting &&
|
{isDeleting &&
|
||||||
<ConfirmRecipeDeleteModal
|
<ConfirmRecipeDeleteModal
|
||||||
cancelHandler={() => setIsDeleting(false)}
|
cancelHandler={() => setIsDeleting(false)}
|
||||||
deleteHandler={() => void deleteRecipe(recipe.Id)}
|
deleteHandler={() => void deleteRecipe(recipe.Id)}
|
||||||
/>}
|
/>}
|
||||||
|
|
||||||
@ -121,6 +128,7 @@ export default function RecipePage() {
|
|||||||
<ShareButton id={recipe.Id} />
|
<ShareButton id={recipe.Id} />
|
||||||
|
|
||||||
{isAuthor && <DeleteButton clickHandler={deleteHandler} />}
|
{isAuthor && <DeleteButton clickHandler={deleteHandler} />}
|
||||||
|
{isAuthor && <EditButton clickHandler={editHandler} />}
|
||||||
</section>
|
</section>
|
||||||
<div className="px-4 py-8 md:px-8">
|
<div className="px-4 py-8 md:px-8">
|
||||||
<h3 className="text-xl text-gray-800 font-semibold mb-2">About this recipe</h3>
|
<h3 className="text-xl text-gray-800 font-semibold mb-2">About this recipe</h3>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import type { CreateRecipeRequest, CreateRecipeResponse, DeleteRecipeResponse, GetRecipeOfTheWeekResponse, GetRecipeResponse, IsRecipeOwnerResponse, SearchRecipesResponse } from "../types/api/recipe";
|
import type { CreateRecipeRequest, CreateRecipeResponse, DeleteRecipeResponse, EditRecipeRequest, GetRecipeOfTheWeekResponse, GetRecipeResponse, IsRecipeOwnerResponse, SearchRecipesResponse, EditRecipeResponse } from "../types/api/recipe";
|
||||||
import type { Recipe } from "../types/recipe";
|
import type { Recipe } from "../types/recipe";
|
||||||
import type { ApiError } from "../types/api/error";
|
import type { ApiError } from "../types/api/error";
|
||||||
import type { SearchFilters } from "../types/search";
|
import type { SearchFilters } from "../types/search";
|
||||||
@ -64,6 +64,20 @@ export async function CreateRecipe(data: CreateRecipeRequest): Promise<Recipe |
|
|||||||
return response.data.recipe;
|
return response.data.recipe;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function EditRecipe(data: EditRecipeRequest): Promise<Recipe | ApiError> {
|
||||||
|
const response = await axios.put<EditRecipeResponse>(`${BACKEND_URL}/v2/api/recipe/${data.Id}`, 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;
|
||||||
|
}
|
||||||
|
|
||||||
export async function DeleteRecipe(id: number): Promise<ApiError | null> {
|
export async function DeleteRecipe(id: number): Promise<ApiError | null> {
|
||||||
const response = await axios.delete<DeleteRecipeResponse>(`${BACKEND_URL}/v2/api/recipe/${id}`);
|
const response = await axios.delete<DeleteRecipeResponse>(`${BACKEND_URL}/v2/api/recipe/${id}`);
|
||||||
|
|
||||||
|
|||||||
@ -24,6 +24,12 @@ export interface CreateRecipeResponse {
|
|||||||
recipe?: Recipe;
|
recipe?: Recipe;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EditRecipeResponse {
|
||||||
|
status: number;
|
||||||
|
message: string;
|
||||||
|
recipe?: Recipe;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CreateRecipeRequest {
|
export interface CreateRecipeRequest {
|
||||||
Title: string;
|
Title: string;
|
||||||
Description: string;
|
Description: string;
|
||||||
@ -37,6 +43,20 @@ export interface CreateRecipeRequest {
|
|||||||
Tags: string[];
|
Tags: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EditRecipeRequest {
|
||||||
|
Id: number;
|
||||||
|
Title: string;
|
||||||
|
Description: string;
|
||||||
|
Instructions: RecipeInstruction[];
|
||||||
|
Serves: number;
|
||||||
|
Difficulty: number;
|
||||||
|
Duration: RecipeDuration;
|
||||||
|
Category: RecipeMeal;
|
||||||
|
Ingredients: RecipeIngredient[];
|
||||||
|
Sections: RecipeIngredientSection[];
|
||||||
|
Tags: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface DeleteRecipeResponse {
|
export interface DeleteRecipeResponse {
|
||||||
status: number;
|
status: number;
|
||||||
message: string;
|
message: string;
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
|
|
||||||
export type EngagementType = "made" | "liked" | "viewed" | "shared" | "reviewed" | "rated";
|
export type EngagementType = "made" | "liked" | "viewed" | "shared" | "reviewed" | "rated" | "created" | "deleted" | "edited";
|
||||||
|
|
||||||
export interface Engagement {
|
export interface Engagement {
|
||||||
Id: number;
|
Id: number;
|
||||||
|
|||||||
@ -10,6 +10,7 @@ const ROUTE_CONSTANTS: {
|
|||||||
History: string;
|
History: string;
|
||||||
Search: string;
|
Search: string;
|
||||||
Recipe: (id: number) => string;
|
Recipe: (id: number) => string;
|
||||||
|
Edit: (id: number) => string;
|
||||||
} = {
|
} = {
|
||||||
Home: `${VERSION_FLAG}/web/home`,
|
Home: `${VERSION_FLAG}/web/home`,
|
||||||
Favorites: `${VERSION_FLAG}/web/favorites`,
|
Favorites: `${VERSION_FLAG}/web/favorites`,
|
||||||
@ -20,6 +21,7 @@ const ROUTE_CONSTANTS: {
|
|||||||
History: `${VERSION_FLAG}/web/history`,
|
History: `${VERSION_FLAG}/web/history`,
|
||||||
Search: `${VERSION_FLAG}/web/search`,
|
Search: `${VERSION_FLAG}/web/search`,
|
||||||
Recipe: (id: number) => `${VERSION_FLAG}/web/recipe/${id}`,
|
Recipe: (id: number) => `${VERSION_FLAG}/web/recipe/${id}`,
|
||||||
|
Edit: (id: number) => `${VERSION_FLAG}/web/create?edit=${id}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ROUTE_CONSTANTS;
|
export default ROUTE_CONSTANTS;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user