(FEAT): Deletion is implemented! #84

Merged
azpect merged 2 commits from feature/delete-recipes into master 2026-01-30 23:45:26 -07:00
23 changed files with 375 additions and 125 deletions

View File

@ -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.",
})
}

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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 () => {
if (clicked) return;
console.log(window.location);
// Copy first, so it feels fast
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">
Serves {recipe.Serves}
</p>
<p className="text-sm text-wrap w-80">
<p className="text-sm text-wrap w-80 break-all">
{recipe.Description}
</p>
<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">
<Navigation />
<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 />
</div>
</div>
</div>
</>
);
}

View File

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

View File

@ -1,8 +1,8 @@
import { useEffect, useState } from "react";
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 { useParams } from "react-router-dom";
import { useNavigate, useParams } from "react-router-dom";
import RecipePlaceholder from "../assets/images/recipe_placeholder_wide.jpg"
import RecipeMetaData from "../components/display/RecipeMetaData";
@ -15,6 +15,9 @@ import InstructionList from "../components/items/InstructionList";
import Spinner from "../components/Spinner";
import { GetUser } from "../services/UserService";
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() {
// Url params
@ -25,44 +28,86 @@ export default function RecipePage() {
const [author, setAuthor] = useState<User | null>(null);
const [error, setError] = useState<string>("");
useEffect(() => {
async function fetch() {
const result: Recipe | ApiError = await GetRecipe(Number(id));
const [isAuthor, setIsAuthor] = useState<boolean>(false);
const [isDeleting, setIsDeleting] = useState<boolean>(false);
const navigate = useNavigate();
// Functions
const getRecipe = async (id: number) => {
const result: Recipe | ApiError = await GetRecipe(id);
if (isApiError(result)) {
setError(result.message);
} else {
setRecipe(result);
}
}
void fetch();
}, [id]);
useEffect(() => {
async function fetch() {
if (!recipe) return;
const result: User | ApiError = await GetUser(recipe.UserId);
const getAuthor = async (id: number) => {
const result: User | ApiError = await GetUser(id);
if (isApiError(result)) {
setError(result.message);
} else {
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]);
useEffect(() => {
void getIsAuthor();
}, [recipe, author]);
// BUG: Prob remove
useEffect(() => {
if (error)
console.error(error);
}, [error]);
useEffect(() => {
console.log(recipe?.Description);
}, [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} />
<div className="px-4 py-8 md:px-8">
<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>
</div>
<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} />
<MadeButton id={recipe.Id} />
<ShareButton id={recipe.Id} />
{isAuthor && <DeleteButton clickHandler={deleteHandler} />}
</section>
<div className="px-4 py-8 md:px-8">
<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 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 { ApiError } from "../types/api/error";
import type { SearchFilters } from "../types/search";
@ -63,3 +63,31 @@ export async function CreateRecipe(data: CreateRecipeRequest): Promise<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[];
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;
Tags: Tag[];
Favorite: boolean;
Deleted: boolean;
}