(FEAT): Deletion is implemented!

This should be a nice test! Hopefully other users cannot delete my
recipes. Though they're setup to soft delete.
This commit is contained in:
Hayden Hargreaves 2026-01-30 23:44:21 -07:00
parent 396ca08381
commit b8c9fc469e
23 changed files with 375 additions and 125 deletions

View File

@ -7,6 +7,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
domain "github.com/haydenhargreaves/Potion/internal/domain/recipe" 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. // GetRecipeOfTheWeekHandler fetchs the current recipe of the week and returns it.
@ -129,3 +130,70 @@ func (s *Server) CreateRecipeHandlerV2(ctx *gin.Context) {
"recipe": recipe, "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.",
})
}

View File

@ -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.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.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/login", s.GetGoogleAuthUrlHandlerV2)
router_api_v2.GET("/auth/callback", s.GoogleCallbackHandlerV2) router_api_v2.GET("/auth/callback", s.GoogleCallbackHandlerV2)

View File

@ -30,7 +30,7 @@ func NewRecipeService(recipeRepository domain.RecipeRepository, engagementReposi
// CreateRecipe creates a recipe in the database using the recipe repository. This function requires // 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 // 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. // occur.
// //
// TODO: Implement validation in the API. // TODO: Implement validation in the API.
@ -78,85 +78,22 @@ func (s *RecipeService) CreateRecipe(ctx *gin.Context) (*domain.Recipe, error) {
} }
return &recipe, nil return &recipe, nil
}
// title := ctx.PostForm("title") // DeleteRecipe deletes a recipe in the database using the recipe repository. This function requires
// description := ctx.PostForm("description") // the userId of the requesting user - to ensure the user is the owner of the recipe. Any errors will
// preparation := ctx.PostForm("preparation-time") // be returned to caller when/if they occur.
// cook := ctx.PostForm("cook-time") func (s *RecipeService) DeleteRecipe(userId, recipeId int) error {
// serving := ctx.PostForm("serving-size") recipe, err := s.recipeRepository.GetRecipe(recipeId, &userId)
// category := ctx.PostForm("category") if recipe == nil || err != nil {
// difficulty := ctx.PostForm("difficulty") return fmt.Errorf("Recipe does not exist or has been relocated. Please try again. %s", err.Error())
// ingredients := ctx.PostFormArray("ingredients") }
// quantity := ctx.PostFormArray("quantity")
// instructions := ctx.PostFormArray("instructions") if recipe.UserId != userId {
// tags := strings.Split(ctx.PostForm("tags"), ",") return fmt.Errorf("User id does not match. Do you own the target recipe?")
// userId := ctx.MustGet("userId").(int) }
//
// // Have to get the image differently return s.recipeRepository.DeleteRecipe(recipeId)
// 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, // 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) 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)
}

View File

@ -124,6 +124,7 @@ type Recipe struct {
Created time.Time Created time.Time
Tags []Tag Tags []Tag
Favorite bool // Per requesting user Favorite bool // Per requesting user
Deleted bool
} }
// SearchFilters is a model which represents the required filters to complete a recipe search. // SearchFilters is a model which represents the required filters to complete a recipe search.

View File

@ -2,6 +2,7 @@ package domain
type RecipeRepository interface { type RecipeRepository interface {
CreateRecipe(recipe *Recipe) error CreateRecipe(recipe *Recipe) 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)
@ -11,4 +12,5 @@ type RecipeRepository interface {
GetRecipeTags(recipe *Recipe) error GetRecipeTags(recipe *Recipe) error
GetRecipeFavorite(recipe *Recipe, userId int) error GetRecipeFavorite(recipe *Recipe, userId int) error
GetRecipeOfTheWeekId(userId *int) (*int, error) GetRecipeOfTheWeekId(userId *int) (*int, error)
IsRecipeOwner(userId, recipeId int) (bool, error)
} }

View File

@ -6,6 +6,7 @@ import (
type RecipeService interface { type RecipeService interface {
CreateRecipe(ctx *gin.Context) (*Recipe, error) CreateRecipe(ctx *gin.Context) (*Recipe, 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)
GetUserRecipes(userId int) ([]Recipe, error) GetUserRecipes(userId int) ([]Recipe, error)
@ -13,4 +14,5 @@ type RecipeService interface {
GetUserViewedRecipes(userId, limit int) ([]Recipe, error) GetUserViewedRecipes(userId, limit int) ([]Recipe, error)
GetUserMadeRecipes(userId, limit int) ([]Recipe, error) GetUserMadeRecipes(userId, limit int) ([]Recipe, error)
GetRecipeOfTheWeek(userId *int) (*Recipe, error) GetRecipeOfTheWeek(userId *int) (*Recipe, error)
IsRecipeOwner(userId *int, recipeId int) (bool, error)
} }

View File

@ -1,6 +1,6 @@
-- Author: Hayden Hargreaves (hhargreaves2006@gmail.com) -- Author: Hayden Hargreaves (hhargreaves2006@gmail.com)
-- Desc: Create the recipe of the week stored procedure. -- 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() CREATE OR REPLACE PROCEDURE calculate_recipe_of_the_week_procedure()
LANGUAGE plpgsql LANGUAGE plpgsql
@ -20,6 +20,9 @@ BEGIN
NOW() NOW()
FROM FROM
Engagements e Engagements e
JOIN Recipes r
ON r.Id = e.Entity
AND r.Deleted = FALSE
WHERE WHERE
e.Created >= NOW() - INTERVAL '7 days' e.Created >= NOW() - INTERVAL '7 days'
AND e.Entity IS NOT NULL AND e.Entity IS NOT NULL

View File

@ -1,4 +1,3 @@
-- Author: Hayden Hargreaves (hhargreaves2006@gmail.com) -- Author: Hayden Hargreaves (hhargreaves2006@gmail.com)
-- Desc: Updated the E_ENGAGEMENT enum to contain created and deleted. -- Desc: Updated the E_ENGAGEMENT enum to contain created and deleted.
-- Date: 01/10/2026 -- Date: 01/10/2026

View File

@ -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;

View File

@ -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/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/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/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/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

View File

@ -102,15 +102,37 @@ func (r *RecipeRepository) CreateRecipe(recipe *domain.Recipe) error {
return nil 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 // 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 // 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. // 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) { func (r *RecipeRepository) GetRecipe(id int, userId *int) (*domain.Recipe, error) {
query := `SELECT query := `SELECT
id, title, description, instructions, serves, difficulty, duration, category, ingredients, id, title, description, instructions, serves, difficulty, duration, category, ingredients,
userid, modified, created userid, modified, created, deleted
FROM recipes FROM recipes
WHERE id = $1 WHERE id = $1 AND deleted = false;
` `
var durationBytes []byte var durationBytes []byte
@ -122,7 +144,6 @@ func (r *RecipeRepository) GetRecipe(id int, userId *int) (*domain.Recipe, error
&recipe.Id, &recipe.Id,
&recipe.Title, &recipe.Title,
&recipe.Description, &recipe.Description,
// pq.Array(&instructions),
&instructions, &instructions,
&recipe.Serves, &recipe.Serves,
&recipe.Difficulty, &recipe.Difficulty,
@ -132,6 +153,7 @@ func (r *RecipeRepository) GetRecipe(id int, userId *int) (*domain.Recipe, error
&recipe.UserId, &recipe.UserId,
&recipe.Modified, &recipe.Modified,
&recipe.Created, &recipe.Created,
&recipe.Deleted,
); err != nil { ); err != nil {
return nil, fmt.Errorf("Failed to location recipe in database: %s", err.Error()) 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 // 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 // will. Callers are responsible for protecting against double nil results. Any errors will be bubbled
// to the caller. // 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) { func (r *RecipeRepository) GetRecipes(ids []int, userId *int) ([]domain.Recipe, error) {
var recipes []domain.Recipe var recipes []domain.Recipe
@ -220,10 +245,11 @@ func isBitActive(bits, pos int) bool {
// //
// TODO: Pagination is required, to provide infinite scroll. // 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 // 12/28/25: This function has changed, now longer returns the recipes, but their IDs for fetching
// elsewhere. // 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) { func (r *RecipeRepository) SearchRecipes(filters domain.SearchFilters, userId *int, favorites bool) ([]int, error) {
// Compute meals type filters (there are 7 bits) // Compute meals type filters (there are 7 bits)
var mealConditions []string var mealConditions []string
@ -368,6 +394,7 @@ func (r *RecipeRepository) SearchRecipes(filters domain.SearchFilters, userId *i
} }
// Convert and append conditions if provided // Convert and append conditions if provided
conditions = append(conditions, "deleted = false")
if len(conditions) > 0 { if len(conditions) > 0 {
conditionsString := fmt.Sprintf("WHERE %s", strings.Join(conditions, " AND ")) conditionsString := fmt.Sprintf("WHERE %s", strings.Join(conditions, " AND "))
query = fmt.Sprintf("%s %s", query, conditionsString) 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. // 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. // 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) { func (r *RecipeRepository) GetUserRecipesIds(user_id int) ([]int, error) {
query := ` query := `
SELECT id SELECT id
FROM recipes FROM recipes
WHERE userid = $1 WHERE userid = $1 AND deleted = false
ORDER BY created DESC; 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. // 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. // 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) { func (r *RecipeRepository) GetUserFavoriteRecipesIds(id int) ([]int, error) {
query := ` query := `
SELECT r.id SELECT r.id
FROM favorites f FROM favorites f
JOIN recipes r ON r.id = f.recipeid JOIN recipes r ON r.id = f.recipeid
WHERE f.userid = $1 WHERE f.userid = $1 AND deleted = false
ORDER BY f.created DESC; ORDER BY f.created DESC;
` `
rows, err := r.db.Query(query, id) rows, err := r.db.Query(query, id)
@ -595,6 +628,7 @@ func (r *RecipeRepository) GetRecipeOfTheWeekId(userId *int) (*int, error) {
r.id r.id
FROM recipes r FROM recipes r
JOIN recipeoftheweek rw ON rw.recipeid = r.id JOIN recipeoftheweek rw ON rw.recipeid = r.id
WHERE r.deleted = false
ORDER BY rw.created DESC ORDER BY rw.created DESC
LIMIT 1; LIMIT 1;
` `
@ -604,8 +638,27 @@ func (r *RecipeRepository) GetRecipeOfTheWeekId(userId *int) (*int, error) {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, nil 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 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
}

View File

@ -0,0 +1,15 @@
import DeleteIconSmall from "../icons/DeleteIconSmall";
interface DeleteButtonProps {
clickHandler: () => void
}
export default function DeleteButton({ clickHandler }: DeleteButtonProps) {
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">
<DeleteIconSmall />
Delete
</button>
);
}

View File

@ -14,7 +14,6 @@ export default function ShareButton({ id }: ShareButtonProps) {
const clickHandler = async () => { const clickHandler = async () => {
if (clicked) return; if (clicked) return;
console.log(window.location);
// Copy first, so it feels fast // Copy first, so it feels fast
const url = `${window.location.origin}${ROUTE_CONSTANTS.Recipe(id)}`; const url = `${window.location.origin}${ROUTE_CONSTANTS.Recipe(id)}`;

View File

@ -39,7 +39,7 @@ export default function RecipeCardLarge({ recipe }: RecipeCardLargeProps) {
<p className="text-xs overflow-hidden whitespace-nowrap text-ellipsis"> <p className="text-xs overflow-hidden whitespace-nowrap text-ellipsis">
Serves {recipe.Serves} Serves {recipe.Serves}
</p> </p>
<p className="text-sm text-wrap w-80"> <p className="text-sm text-wrap w-80 break-all">
{recipe.Description} {recipe.Description}
</p> </p>
<div className="flex items-end justify-between"> <div className="flex items-end justify-between">

View File

@ -0,0 +1,8 @@
export default function WarningIconLarge() {
return (
<svg className="size-18 text-red-700" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640">
<path d="M320 112C434.9 112 528 205.1 528 320C528 434.9 434.9 528 320 528C205.1 528 112 434.9 112 320C112 205.1 205.1 112 320 112zM320 576C461.4 576 576 461.4 576 320C576 178.6 461.4 64 320 64C178.6 64 64 178.6 64 320C64 461.4 178.6 576 320 576zM231 231C221.6 240.4 221.6 255.6 231 264.9L286 319.9L231 374.9C221.6 384.3 221.6 399.5 231 408.8C240.4 418.1 255.6 418.2 264.9 408.8L319.9 353.8L374.9 408.8C384.3 418.2 399.5 418.2 408.8 408.8C418.1 399.4 418.2 384.2 408.8 374.9L353.8 319.9L408.8 264.9C418.2 255.5 418.2 240.3 408.8 231C399.4 221.7 384.2 221.6 374.9 231L319.9 286L264.9 231C255.5 221.6 240.3 221.6 231 231z" fill="currentColor" />
</svg>
);
}

View File

@ -0,0 +1,8 @@
export default function XIconSmall() {
return (
<svg className="size-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640">
<path d="M504.6 148.5C515.9 134.9 514.1 114.7 500.5 103.4C486.9 92.1 466.7 93.9 455.4 107.5L320 270L184.6 107.5C173.3 93.9 153.1 92.1 139.5 103.4C125.9 114.7 124.1 134.9 135.4 148.5L278.3 320L135.4 491.5C124.1 505.1 125.9 525.3 139.5 536.6C153.1 547.9 173.3 546.1 184.6 532.5L320 370L455.4 532.5C466.7 546.1 486.9 547.9 500.5 536.6C514.1 525.3 515.9 505.1 504.6 491.5L361.7 320L504.6 148.5z" fill="currentColor" />
</svg>
);
}

View File

@ -0,0 +1,46 @@
import WarningIconLarge from "../icons/WarningIconLarge"
import XIconSmall from "../icons/XIconSmall";
interface ConfirmRecipeDeleteModalProps {
cancelHandler: () => void;
deleteHandler: () => void;
}
export default function ConfirmRecipeDeleteModal({ cancelHandler, deleteHandler }: ConfirmRecipeDeleteModalProps) {
return (
<div className="bg-black/25 fixed w-screen h-screen top-0 left-0 flex items-center justify-center select-none">
<div className="bg-white relative max-w-9/10 md:max-w-1/2 lg:max-w-1/4 rounded-sm broder-gray-300 flex flex-col items-center justify-evenly py-8 px-16 gap-y-4">
{/* Close button */}
<button onClick={cancelHandler} className="absolute cursor-pointer top-1 right-1 p-3 duration-100 text-gray-500 hover:text-gray-600">
<XIconSmall />
</button>
<WarningIconLarge />
<h2 className="text-lg md:text-xl"> Are you sure?</h2>
<p className="text-gray-600 text-md text-center">
Are you sure you want to delete this recipe? This action cannot be undone!
</p>
<div className="flex gap-x-4">
<button
onClick={cancelHandler}
className="py-2 px-8 bg-gray-200 rounded-sm cursor-pointer duration-300 hover:bg-gray-300"
>
Cancel
</button>
<button
onClick={deleteHandler}
className="py-2 px-8 bg-red-700 text-white rounded-sm cursor-pointer duration-300 hover:bg-red-800"
>
Delete
</button>
</div>
</div>
</div>
);
}

View File

@ -9,12 +9,12 @@ export default function WebLayout() {
<div className="bg-gray-100 min-h-screen"> <div className="bg-gray-100 min-h-screen">
<Navigation /> <Navigation />
<div className="w-full flex justify-center"> <div className="w-full flex justify-center">
<div className="mx-2 md:mx-0 w-full md:w-1/2 md:pt-14 min-h-screen h-fit border-l border-r border-gray-300 bg-white"> <div className="mx-2 md:mx-0 w-full md:w-1/2 md:pt-14 min-h-screen h-fit border-l border-r
border-gray-300 bg-white relative">
<Outlet /> <Outlet />
</div> </div>
</div> </div>
</div> </div>
</> </>
); );
} }

View File

@ -127,8 +127,6 @@ export default function Create() {
// Functions // Functions
const createRecipe = async (): Promise<void> => { const createRecipe = async (): Promise<void> => {
console.log({ title, description, tags, prepTime, cookTime, servingSize, category, difficulty, sections, ingredients, instructions });
// Exit if not valid recipe meal // Exit if not valid recipe meal
if (!isRecipeMeal(category)) { if (!isRecipeMeal(category)) {
console.error("[ERROR] Recipe meal is invalid."); console.error("[ERROR] Recipe meal is invalid.");

View File

@ -1,8 +1,8 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { isApiError, type ApiError } from "../types/api/error"; import { isApiError, type ApiError } from "../types/api/error";
import { GetRecipe } from "../services/RecipeService"; import { DeleteRecipe, GetRecipe, IsRecipeOwner } from "../services/RecipeService";
import type { Recipe } from "../types/recipe"; import type { Recipe } from "../types/recipe";
import { useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import RecipePlaceholder from "../assets/images/recipe_placeholder_wide.jpg" import RecipePlaceholder from "../assets/images/recipe_placeholder_wide.jpg"
import RecipeMetaData from "../components/display/RecipeMetaData"; import RecipeMetaData from "../components/display/RecipeMetaData";
@ -15,6 +15,9 @@ import InstructionList from "../components/items/InstructionList";
import Spinner from "../components/Spinner"; import Spinner from "../components/Spinner";
import { GetUser } from "../services/UserService"; import { GetUser } from "../services/UserService";
import type { User } from "../types/user"; import type { User } from "../types/user";
import DeleteButton from "../components/buttons/DeleteButton";
import ROUTE_CONSTANTS from "../types/routes";
import ConfirmRecipeDeleteModal from "../components/modals/ConfirmRecipeDeleteModal";
export default function RecipePage() { export default function RecipePage() {
// Url params // Url params
@ -25,44 +28,86 @@ export default function RecipePage() {
const [author, setAuthor] = useState<User | null>(null); const [author, setAuthor] = useState<User | null>(null);
const [error, setError] = useState<string>(""); const [error, setError] = useState<string>("");
useEffect(() => { const [isAuthor, setIsAuthor] = useState<boolean>(false);
async function fetch() { const [isDeleting, setIsDeleting] = useState<boolean>(false);
const result: Recipe | ApiError = await GetRecipe(Number(id));
const navigate = useNavigate();
// Functions
const getRecipe = async (id: number) => {
const result: Recipe | ApiError = await GetRecipe(id);
if (isApiError(result)) { if (isApiError(result)) {
setError(result.message); setError(result.message);
} else { } else {
setRecipe(result); setRecipe(result);
} }
} }
void fetch();
}, [id]);
useEffect(() => { const getAuthor = async (id: number) => {
async function fetch() { const result: User | ApiError = await GetUser(id);
if (!recipe) return;
const result: User | ApiError = await GetUser(recipe.UserId);
if (isApiError(result)) { if (isApiError(result)) {
setError(result.message); setError(result.message);
} else { } else {
setAuthor(result); setAuthor(result);
} }
} }
void fetch();
const getIsAuthor = async () => {
if (!recipe) return;
const response = await IsRecipeOwner(recipe.Id)
if (isApiError(response)) {
setError(response.message);
return;
}
setIsAuthor(response);
}
const deleteRecipe = async (id: number) => {
const error = await DeleteRecipe(id);
if (isApiError(error)) {
setError(error.message)
return;
}
// TODO: Some toast, maybe?
await navigate(ROUTE_CONSTANTS.Home);
}
// Handlers
const deleteHandler = () => {
if (!recipe || !isAuthor) return;
setIsDeleting(true);
}
// Effects
useEffect(() => {
void getRecipe(Number(id));
}, [id]);
useEffect(() => {
if (recipe)
void getAuthor(recipe.UserId);
}, [recipe]); }, [recipe]);
useEffect(() => {
void getIsAuthor();
}, [recipe, author]);
// BUG: Prob remove // BUG: Prob remove
useEffect(() => { useEffect(() => {
if (error) if (error)
console.error(error); console.error(error);
}, [error]); }, [error]);
useEffect(() => {
console.log(recipe?.Description);
}, [recipe]);
return recipe ? ( return recipe ? (
<> <>
{isDeleting &&
<ConfirmRecipeDeleteModal
cancelHandler={() => setIsDeleting(false)}
deleteHandler={() => void deleteRecipe(recipe.Id)}
/>}
<img className="bg-gray-100 w-full h-64 md:h-96 mx-auto mb-8" src={RecipePlaceholder} /> <img className="bg-gray-100 w-full h-64 md:h-96 mx-auto mb-8" src={RecipePlaceholder} />
<div className="px-4 py-8 md:px-8"> <div className="px-4 py-8 md:px-8">
<h1 className="text-3xl md:text-4xl font-bold text-gray-800">{recipe.Title}</h1> <h1 className="text-3xl md:text-4xl font-bold text-gray-800">{recipe.Title}</h1>
@ -70,10 +115,12 @@ export default function RecipePage() {
<p className="text-sm mb-2 text-gray-700">Category: {recipe.Category}</p> <p className="text-sm mb-2 text-gray-700">Category: {recipe.Category}</p>
</div> </div>
<RecipeMetaData recipe={recipe} /> <RecipeMetaData recipe={recipe} />
<section className="w-full flex flex-col md:flex-row gap-x-4 gap-y-2 py-8 px-4 md:px-8"> <section className="w-full flex flex-col md:flex-row gap-x-4 gap-y-2 py-8 px-4 md:px-8 flex-wrap">
<FavoriteButton favorite={recipe.Favorite} id={recipe.Id} /> <FavoriteButton favorite={recipe.Favorite} id={recipe.Id} />
<MadeButton id={recipe.Id} /> <MadeButton id={recipe.Id} />
<ShareButton id={recipe.Id} /> <ShareButton id={recipe.Id} />
{isAuthor && <DeleteButton clickHandler={deleteHandler} />}
</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>

View File

@ -1,5 +1,5 @@
import axios from "axios"; import axios from "axios";
import type { CreateRecipeRequest, CreateRecipeResponse, GetRecipeOfTheWeekResponse, GetRecipeResponse, SearchRecipesResponse } from "../types/api/recipe"; import type { CreateRecipeRequest, CreateRecipeResponse, DeleteRecipeResponse, GetRecipeOfTheWeekResponse, GetRecipeResponse, IsRecipeOwnerResponse, SearchRecipesResponse } 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";
@ -63,3 +63,31 @@ export async function CreateRecipe(data: CreateRecipeRequest): Promise<Recipe |
return response.data.recipe; return response.data.recipe;
} }
export async function DeleteRecipe(id: number): Promise<ApiError | null> {
const response = await axios.delete<DeleteRecipeResponse>(`${BACKEND_URL}/v2/api/recipe/${id}`);
if (response.status !== 200) {
const err: ApiError = {
status: response.data.status,
message: response.data.message
};
return err;
}
return null;
}
export async function IsRecipeOwner(recipeId: number): Promise<boolean | ApiError> {
const response = await axios.get<IsRecipeOwnerResponse>(`${BACKEND_URL}/v2/api/recipe/${recipeId}/is-owner`);
if (response.status !== 200) {
const err: ApiError = {
status: response.data.status,
message: response.data.message
};
return err;
}
return response.data.owner;
}

View File

@ -36,3 +36,14 @@ export interface CreateRecipeRequest {
Sections: RecipeIngredientSection[]; Sections: RecipeIngredientSection[];
Tags: string[]; Tags: string[];
} }
export interface DeleteRecipeResponse {
status: number;
message: string;
}
export interface IsRecipeOwnerResponse {
owner: boolean;
status: number;
message: string;
}

View File

@ -93,4 +93,5 @@ export interface Recipe {
Created: Date; Created: Date;
Tags: Tag[]; Tags: Tag[];
Favorite: boolean; Favorite: boolean;
Deleted: boolean;
} }