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

Reviewed-on: #87
This commit is contained in:
Hayden Hargreaves 2026-02-01 00:30:45 -07:00
commit 1e88f075cb
28 changed files with 524 additions and 51 deletions

View File

@ -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")

View File

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

View File

@ -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) {

View File

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

View File

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

View File

@ -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)
} }

View File

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

View File

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

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)
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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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>
);
}

View File

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

View File

@ -35,7 +35,7 @@ export default function MadeButton({ id }: MadeButtonProps) {
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

View File

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

View File

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

View 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>
);
}

View File

@ -72,6 +72,7 @@ export function useIngredients() {
return { return {
sections, sections,
ingredients, ingredients,
setIngredients,
setSections, setSections,
sectionChange, sectionChange,
ingredientChange, ingredientChange,

View File

@ -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,8 +288,12 @@ export default function Create() {
return; return;
} }
if (editingId) {
void editRecipe();
} else {
void createRecipe(); void createRecipe();
} }
}
// EFFECTS // EFFECTS
@ -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">
{editingId ? (
<>
Welcome back! Update your recipe by modifying any of the details below, including the recipe's name,
description, category, duration, difficulty, ingredients, and instructions. You can add or remove
ingredients and instruction steps using the dedicated buttons, and update the recipe image if desired.
All required fields are marked with an <span className="text-red-500">*</span>. Once you're happy
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, 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, 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 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 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" marked with an <span className="text-red-500">*</span>. Once everything looks perfect, just
button to hit the "Create Recipe" button to share your masterpiece!
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>

View File

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

View File

@ -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}`);

View File

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

View File

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

View File

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