diff --git a/internal/app/server/recipe_handler_v2.go b/internal/app/server/recipe_handler_v2.go index 6792ea2..ec45e6a 100644 --- a/internal/app/server/recipe_handler_v2.go +++ b/internal/app/server/recipe_handler_v2.go @@ -7,6 +7,7 @@ import ( "github.com/gin-gonic/gin" domain "github.com/haydenhargreaves/Potion/internal/domain/recipe" + domainUser "github.com/haydenhargreaves/Potion/internal/domain/user" ) // GetRecipeOfTheWeekHandler fetchs the current recipe of the week and returns it. @@ -129,3 +130,70 @@ func (s *Server) CreateRecipeHandlerV2(ctx *gin.Context) { "recipe": recipe, }) } + +func (s *Server) DeleteRecipeHandlerV2(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 + } + + _, err = s.deps.EngagementService.UserDeleteRecipe(user.Id, parsedId) + 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 + } + + if err := s.deps.RecipeService.DeleteRecipe(user.Id, parsedId); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{ + "status": http.StatusBadRequest, + "message": fmt.Sprintf("[ERROR] Failed to delete recipe. %s", err.Error()), + }) + return + } + + ctx.JSON(http.StatusOK, gin.H{ + "status": http.StatusOK, + "message": "[OK] Successfully deleted recipe.", + }) + }) +} + +func (s *Server) IsRecipeOwnerV2(ctx *gin.Context) { + userId := getUserId(ctx) + + id := ctx.Param("id") + parsedId, err := strconv.Atoi(id) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{ + "owner": false, + "status": http.StatusBadRequest, + "message": fmt.Sprintf("[ERROR] Failed to parse ID parameter. %s", err.Error()), + }) + return + } + + isOwner, err := s.deps.RecipeService.IsRecipeOwner(userId, parsedId) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{ + "owner": false, + "status": http.StatusBadRequest, + "message": fmt.Sprintf("[ERROR] Failed to determine is user is recipe owner.", err.Error()), + }) + return + } + + ctx.JSON(http.StatusOK, gin.H{ + "owner": isOwner, + "status": http.StatusOK, + "message": "[OK] Successfully determined recipe ownership status.", + }) +} diff --git a/internal/app/server/server.go b/internal/app/server/server.go index 16c0387..6f99457 100644 --- a/internal/app/server/server.go +++ b/internal/app/server/server.go @@ -263,6 +263,8 @@ func (s *Server) Setup() *Server { 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.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("/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 acb5baa..1bc9d8c 100644 --- a/internal/app/service/recipe_service.go +++ b/internal/app/service/recipe_service.go @@ -30,7 +30,7 @@ func NewRecipeService(recipeRepository domain.RecipeRepository, engagementReposi // CreateRecipe creates a recipe in the database using the recipe repository. This function requires // all the data to be present, though validation does not occur in this function. However, the UI -// will enforce validation, as will the database. Errors will be returned to the called when they +// will enforce validation, as will the database. Errors will be returned to the caller when they // occur. // // TODO: Implement validation in the API. @@ -78,85 +78,22 @@ func (s *RecipeService) CreateRecipe(ctx *gin.Context) (*domain.Recipe, 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 +// 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 +// be returned to caller when/if they occur. +func (s *RecipeService) DeleteRecipe(userId, recipeId int) error { + recipe, err := s.recipeRepository.GetRecipe(recipeId, &userId) + if recipe == nil || err != nil { + return fmt.Errorf("Recipe does not exist or has been relocated. Please try again. %s", err.Error()) + } + + if recipe.UserId != userId { + return fmt.Errorf("User id does not match. Do you own the target recipe?") + } + + return s.recipeRepository.DeleteRecipe(recipeId) } // GetRecipe will get a recipe via its ID. Any errors will be bubbled to the caller. Furthermore, @@ -271,3 +208,15 @@ func (s *RecipeService) GetRecipeOfTheWeek(userId *int) (*domain.Recipe, error) return s.recipeRepository.GetRecipe(*id, userId) } + +// IsRecipeOwner takes an optional userId and a recipeId. If the userId is nil (not given) this +// function will return false. Otherwise, it will query the database to find out of the user is +// the owner of the recipe. Any error will be bubbled to the caller. +func (s *RecipeService) IsRecipeOwner(userId *int, recipeId int) (bool, error) { + // No user, obviously not the user. + if userId == nil { + return false, nil + } + + return s.recipeRepository.IsRecipeOwner(*userId, recipeId) +} diff --git a/internal/domain/recipe/recipe.go b/internal/domain/recipe/recipe.go index 52ddbd6..49fa8aa 100644 --- a/internal/domain/recipe/recipe.go +++ b/internal/domain/recipe/recipe.go @@ -124,6 +124,7 @@ type Recipe struct { Created time.Time Tags []Tag Favorite bool // Per requesting user + Deleted bool } // SearchFilters is a model which represents the required filters to complete a recipe search. diff --git a/internal/domain/recipe/repository.go b/internal/domain/recipe/repository.go index 93de1f4..c91a616 100644 --- a/internal/domain/recipe/repository.go +++ b/internal/domain/recipe/repository.go @@ -2,6 +2,7 @@ package domain type RecipeRepository interface { CreateRecipe(recipe *Recipe) error + DeleteRecipe(recipeId int) error GetRecipe(id int, userId *int) (*Recipe, error) GetRecipes(ids []int, userId *int) ([]Recipe, error) SearchRecipes(filters SearchFilters, userId *int, favorites bool) ([]int, error) @@ -11,4 +12,5 @@ type RecipeRepository interface { GetRecipeTags(recipe *Recipe) error GetRecipeFavorite(recipe *Recipe, userId int) error GetRecipeOfTheWeekId(userId *int) (*int, error) + IsRecipeOwner(userId, recipeId int) (bool, error) } diff --git a/internal/domain/recipe/service.go b/internal/domain/recipe/service.go index 9c6a75f..c7f67b9 100644 --- a/internal/domain/recipe/service.go +++ b/internal/domain/recipe/service.go @@ -6,6 +6,7 @@ import ( type RecipeService interface { CreateRecipe(ctx *gin.Context) (*Recipe, error) + DeleteRecipe(userId, recipeId int) error GetRecipe(id int, userId *int) (*Recipe, error) SearchRecipes(filters SearchFilters, userId *int, favorites bool) ([]Recipe, error) GetUserRecipes(userId int) ([]Recipe, error) @@ -13,4 +14,5 @@ type RecipeService interface { GetUserViewedRecipes(userId, limit int) ([]Recipe, error) GetUserMadeRecipes(userId, limit int) ([]Recipe, error) GetRecipeOfTheWeek(userId *int) (*Recipe, error) + IsRecipeOwner(userId *int, recipeId int) (bool, error) } diff --git a/internal/infrastructure/database/migrations/010_create_recipe_of_the_week_procedure.sql b/internal/infrastructure/database/migrations/010_create_recipe_of_the_week_procedure.sql index 0ddcb6f..1680b0e 100644 --- a/internal/infrastructure/database/migrations/010_create_recipe_of_the_week_procedure.sql +++ b/internal/infrastructure/database/migrations/010_create_recipe_of_the_week_procedure.sql @@ -1,6 +1,6 @@ -- Author: Hayden Hargreaves (hhargreaves2006@gmail.com) -- Desc: Create the recipe of the week stored procedure. --- Date: 07/26/2025 +-- Date: 07/26/2025, 1/10/2026 CREATE OR REPLACE PROCEDURE calculate_recipe_of_the_week_procedure() LANGUAGE plpgsql @@ -20,6 +20,9 @@ BEGIN NOW() FROM Engagements e + JOIN Recipes r + ON r.Id = e.Entity + AND r.Deleted = FALSE WHERE e.Created >= NOW() - INTERVAL '7 days' AND e.Entity IS NOT NULL diff --git a/internal/infrastructure/database/migrations/011_update_engagement_enum.sql b/internal/infrastructure/database/migrations/011_update_engagement_enum.sql index 62baf3e..0486e78 100644 --- a/internal/infrastructure/database/migrations/011_update_engagement_enum.sql +++ b/internal/infrastructure/database/migrations/011_update_engagement_enum.sql @@ -1,4 +1,3 @@ - -- Author: Hayden Hargreaves (hhargreaves2006@gmail.com) -- Desc: Updated the E_ENGAGEMENT enum to contain created and deleted. -- Date: 01/10/2026 diff --git a/internal/infrastructure/database/migrations/012_update_recipes_table_deleted_column.sql b/internal/infrastructure/database/migrations/012_update_recipes_table_deleted_column.sql new file mode 100644 index 0000000..6e7b1bc --- /dev/null +++ b/internal/infrastructure/database/migrations/012_update_recipes_table_deleted_column.sql @@ -0,0 +1,10 @@ +-- Author: Hayden Hargreaves (hhargreaves2006@gmail.com) +-- Desc: Updated the E_ENGAGEMENT enum to contain created and deleted. +-- Date: 01/10/2026 + +BEGIN; + +ALTER TABLE recipes + ADD COLUMN Deleted BOOLEAN NOT NULL DEFAULT FALSE; + +COMMIT; diff --git a/internal/infrastructure/database/migrations/100_init_database.sh b/internal/infrastructure/database/migrations/100_init_database.sh index 73dd550..9a415ab 100755 --- a/internal/infrastructure/database/migrations/100_init_database.sh +++ b/internal/infrastructure/database/migrations/100_init_database.sh @@ -11,7 +11,7 @@ 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/009_create_recipe_of_the_week_table.sql psql -h "$PSQL_HOST" -U "$PSQL_USERNAME" -d "$PSQL_DATABASE" -f ./internal/infrastructure/database/migrations/010_create_recipe_of_the_week_procedure.sql psql -h "$PSQL_HOST" -U "$PSQL_USERNAME" -d "$PSQL_DATABASE" -f ./internal/infrastructure/database/migrations/011_update_engagement_enum.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/014_create_logs_table.sql diff --git a/internal/infrastructure/database/repository/recipe_repository.go b/internal/infrastructure/database/repository/recipe_repository.go index ea8376a..096ec16 100644 --- a/internal/infrastructure/database/repository/recipe_repository.go +++ b/internal/infrastructure/database/repository/recipe_repository.go @@ -102,15 +102,37 @@ func (r *RecipeRepository) CreateRecipe(recipe *domain.Recipe) error { return nil } +// 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, +// so the caller should validate the owner. If any errors occur, they will be returned to the caller. +func (r *RecipeRepository) DeleteRecipe(recipeId int) error { + query := "UPDATE recipes SET deleted = TRUE WHERE id = $1" + + result, err := r.db.Exec(query, recipeId) + if err != nil { + return err + } + + rows, _ := result.RowsAffected() + if rows != 1 { + return fmt.Errorf("[ERROR] Incorrect number of rows modified. Expected 1, received %d.", rows) + } + + return nil +} + // GetRecipe gets a recipe from the database via its ID. The operation is wrapped in a transaction // 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. +// +// This function will only return recipes that are not deleted. Any recipes marked deleted will be ignored +// and the standard "not-found" error will be returned. 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 + userid, modified, created, deleted FROM recipes - WHERE id = $1 + WHERE id = $1 AND deleted = false; ` var durationBytes []byte @@ -122,7 +144,6 @@ func (r *RecipeRepository) GetRecipe(id int, userId *int) (*domain.Recipe, error &recipe.Id, &recipe.Title, &recipe.Description, - // pq.Array(&instructions), &instructions, &recipe.Serves, &recipe.Difficulty, @@ -132,6 +153,7 @@ func (r *RecipeRepository) GetRecipe(id int, userId *int) (*domain.Recipe, error &recipe.UserId, &recipe.Modified, &recipe.Created, + &recipe.Deleted, ); err != nil { return nil, fmt.Errorf("Failed to location recipe in database: %s", err.Error()) } @@ -188,6 +210,9 @@ func (r *RecipeRepository) GetRecipe(id int, userId *int) (*domain.Recipe, error // transaction 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. +// +// This function calls a function that only returns recipes that are not deleted. Any recipes marked +// deleted will be ignored and the standard "not-found" error will be returned. func (r *RecipeRepository) GetRecipes(ids []int, userId *int) ([]domain.Recipe, error) { var recipes []domain.Recipe @@ -220,10 +245,11 @@ func isBitActive(bits, pos int) bool { // // TODO: Pagination is required, to provide infinite scroll. // -// TODO: This does not work in the current build, the DB does not return valid values. -// // 12/28/25: This function has changed, now longer returns the recipes, but their IDs for fetching // elsewhere. +// +// This function will only return recipes that are not deleted. Any recipes marked deleted will be ignored +// and the standard "not-found" error will be returned. func (r *RecipeRepository) SearchRecipes(filters domain.SearchFilters, userId *int, favorites bool) ([]int, error) { // Compute meals type filters (there are 7 bits) var mealConditions []string @@ -368,6 +394,7 @@ func (r *RecipeRepository) SearchRecipes(filters domain.SearchFilters, userId *i } // Convert and append conditions if provided + conditions = append(conditions, "deleted = false") if len(conditions) > 0 { conditionsString := fmt.Sprintf("WHERE %s", strings.Join(conditions, " AND ")) query = fmt.Sprintf("%s %s", query, conditionsString) @@ -465,11 +492,14 @@ func (r *RecipeRepository) CreateRecipeTags(recipe domain.Recipe, tags []string) // is sorted by the created dates, newest first. Any errors will be bubbled to the caller. // // 12/28/25: This now returns just the IDs, the service can handle fetching them. +// +// This function will only return recipes that are not deleted. Any recipes marked deleted will be ignored +// and the standard "not-found" error will be returned. func (r *RecipeRepository) GetUserRecipesIds(user_id int) ([]int, error) { query := ` SELECT id FROM recipes - WHERE userid = $1 + WHERE userid = $1 AND deleted = false ORDER BY created DESC; ` @@ -497,12 +527,15 @@ func (r *RecipeRepository) GetUserRecipesIds(user_id int) ([]int, error) { // is sorted by the created dates, newest first. Any errors will be bubbled to the caller. // // 12/28/25: This now just returns the IDs, so the service can handle the fetching. +// +// This function will only return recipes that are not deleted. Any recipes marked deleted will be ignored +// and the standard "not-found" error will be returned. func (r *RecipeRepository) GetUserFavoriteRecipesIds(id int) ([]int, error) { query := ` SELECT r.id FROM favorites f JOIN recipes r ON r.id = f.recipeid - WHERE f.userid = $1 + WHERE f.userid = $1 AND deleted = false ORDER BY f.created DESC; ` rows, err := r.db.Query(query, id) @@ -595,6 +628,7 @@ func (r *RecipeRepository) GetRecipeOfTheWeekId(userId *int) (*int, error) { r.id FROM recipes r JOIN recipeoftheweek rw ON rw.recipeid = r.id + WHERE r.deleted = false ORDER BY rw.created DESC LIMIT 1; ` @@ -604,8 +638,27 @@ func (r *RecipeRepository) GetRecipeOfTheWeekId(userId *int) (*int, error) { if errors.Is(err, sql.ErrNoRows) { return nil, nil } - return nil, fmt.Errorf("Failed to location recipe in database: %s", err.Error()) + return nil, fmt.Errorf("Failed to locate recipe in database: %s", err.Error()) } return &id, nil } + +// IsRecipeOwner takes two required arguments: a user id and a recipe id. This function queries the DB +// to check if the user is the owner of the provided recipe. Any error will be bubbled to the caller. +func (r *RecipeRepository) IsRecipeOwner(userId, recipeId int) (bool, error) { + query := ` + SELECT + userid + FROM recipes + WHERE deleted = false + AND id = $1; + ` + + var recipeOwnerId int + if err := r.db.QueryRow(query, recipeId).Scan(&recipeOwnerId); err != nil { + return false, fmt.Errorf("Failed to get recipe owner id: %s", err.Error()) + } + + return recipeOwnerId == userId, nil +} diff --git a/web/src/components/buttons/DeleteButton.tsx b/web/src/components/buttons/DeleteButton.tsx new file mode 100644 index 0000000..7f62528 --- /dev/null +++ b/web/src/components/buttons/DeleteButton.tsx @@ -0,0 +1,15 @@ +import DeleteIconSmall from "../icons/DeleteIconSmall"; + +interface DeleteButtonProps { + clickHandler: () => void +} + +export default function DeleteButton({ clickHandler }: DeleteButtonProps) { + return ( + + ); + +} diff --git a/web/src/components/buttons/ShareButton.tsx b/web/src/components/buttons/ShareButton.tsx index a33459f..e210209 100644 --- a/web/src/components/buttons/ShareButton.tsx +++ b/web/src/components/buttons/ShareButton.tsx @@ -14,7 +14,6 @@ export default function ShareButton({ id }: ShareButtonProps) { const clickHandler = async () => { if (clicked) return; - console.log(window.location); // Copy first, so it feels fast const url = `${window.location.origin}${ROUTE_CONSTANTS.Recipe(id)}`; diff --git a/web/src/components/cards/RecipeCardLarge.tsx b/web/src/components/cards/RecipeCardLarge.tsx index 00da616..08614c3 100644 --- a/web/src/components/cards/RecipeCardLarge.tsx +++ b/web/src/components/cards/RecipeCardLarge.tsx @@ -39,7 +39,7 @@ export default function RecipeCardLarge({ recipe }: RecipeCardLargeProps) {
Serves {recipe.Serves}
-+
{recipe.Description}
+ Are you sure you want to delete this recipe? This action cannot be undone! +
+ +Category: {recipe.Category}