From c68cb72ffb622c00377794bbf67c8c0e79a6262f Mon Sep 17 00:00:00 2001 From: Hayden Hargreaves Date: Wed, 19 Nov 2025 13:44:41 -0700 Subject: [PATCH] (FEAT): Engagement for recipes is translated. --- internal/app/server/authentication.go | 30 ++-- internal/app/server/engagement_handler_v2.go | 143 ++++++++++++++++++ internal/app/server/server.go | 5 + internal/app/server/user_handler_v2.go | 10 +- web/src/components/Spinner.tsx | 2 +- web/src/components/buttons/FavoriteButton.tsx | 32 +++- web/src/components/buttons/MadeButton.tsx | 29 +++- web/src/components/buttons/ShareButton.tsx | 17 ++- web/src/components/cards/RecipeCardLarge.tsx | 17 ++- web/src/components/cards/RecipeCardSmall.tsx | 16 +- web/src/components/items/InstructionList.tsx | 20 +-- web/src/pages/Recipe.tsx | 1 - web/src/services/EngagementService.ts | 60 ++++++++ web/src/types/api/engagement.ts | 26 ++++ 14 files changed, 360 insertions(+), 48 deletions(-) create mode 100644 internal/app/server/engagement_handler_v2.go create mode 100644 web/src/services/EngagementService.ts create mode 100644 web/src/types/api/engagement.ts diff --git a/internal/app/server/authentication.go b/internal/app/server/authentication.go index edb791f..81d2283 100644 --- a/internal/app/server/authentication.go +++ b/internal/app/server/authentication.go @@ -14,12 +14,17 @@ type AuthenticatedFunc func(ctx *gin.Context, user *domain.User) // the function will return an error with a 401 status. // // BUG: This is probably not very effecient, since we hit the DB on every single protected request. -// If this ends up being a bottle neck we could simply hit the context for the userId, since -// that is usually all we need...Or maybe have two methods, for those that need the whole user -// and those that just need the ID. +// +// If this ends up being a bottle neck we could simply hit the context for the userId, since +// that is usually all we need...Or maybe have two methods, for those that need the whole user +// and those that just need the ID. func (s *Server) withAuthenticatedUser(ctx *gin.Context, handler AuthenticatedFunc) { user := s.deps.UserService.GetAuthenicatedUser(ctx) if user == nil { + // User is stale, ensure they are logged out so they can be prompted to log back in + s.SetCookie(ctx, "jwt_token", "", -1) + // s.SetCookie(ctx, "search-filters", "", -1) // TODO: Might need this again + ctx.JSON(http.StatusUnauthorized, gin.H{ "status": http.StatusUnauthorized, "message": "[UNAUTHORIZED] Could not fetch authenticated user.", @@ -29,17 +34,16 @@ func (s *Server) withAuthenticatedUser(ctx *gin.Context, handler AuthenticatedFu handler(ctx, user) } - // getUserId retrieves the userId from the context and returns a pointer to it. A nil // pointer can be returned and will if the userId does not exist. func getUserId(ctx *gin.Context) *int { - userIdAny, exists := ctx.Get("userId") - if !exists { - return nil - } - userIdInt, ok := userIdAny.(int) - if !ok { - return nil - } - return &userIdInt + userIdAny, exists := ctx.Get("userId") + if !exists { + return nil + } + userIdInt, ok := userIdAny.(int) + if !ok { + return nil + } + return &userIdInt } diff --git a/internal/app/server/engagement_handler_v2.go b/internal/app/server/engagement_handler_v2.go new file mode 100644 index 0000000..2fcd806 --- /dev/null +++ b/internal/app/server/engagement_handler_v2.go @@ -0,0 +1,143 @@ +package server + +import ( + "fmt" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + domain "github.com/haydenhargreaves/Potion/internal/domain/user" +) + +func (s *Server) EngagementViewRecipeHandlerV2(ctx *gin.Context) { + recipeId, err := strconv.Atoi(ctx.Param("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 + } + + userId := getUserId(ctx) + if userId == nil { + if engagement, err := s.deps.EngagementService.ViewRecipe(recipeId); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{ + "status": http.StatusBadRequest, + "message": fmt.Sprintf("[ERROR] Failed to create engagement. %s", err.Error()), + }) + } else { + ctx.JSON(http.StatusOK, gin.H{ + "status": http.StatusOK, + "message": "[OK] Successfully created engagement.", + "engagement": engagement, + }) + } + return + } + if engagement, err := s.deps.EngagementService.UserViewRecipe(*userId, recipeId); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{ + "status": http.StatusBadRequest, + "message": fmt.Sprintf("[ERROR] Failed to create engagement. %s", err.Error()), + }) + } else { + ctx.JSON(http.StatusOK, gin.H{ + "status": http.StatusOK, + "message": "[OK] Successfully created engagement.", + "engagement": engagement, + }) + } +} + +func (s *Server) EngagementShareRecipeHandlerV2(ctx *gin.Context) { + recipeId, err := strconv.Atoi(ctx.Param("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 + } + + userId := getUserId(ctx) + if userId == nil { + if engagement, err := s.deps.EngagementService.ShareRecipe(recipeId); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{ + "status": http.StatusBadRequest, + "message": fmt.Sprintf("[ERROR] Failed to create engagement. %s", err.Error()), + }) + } else { + ctx.JSON(http.StatusOK, gin.H{ + "status": http.StatusOK, + "message": "[OK] Successfully created engagement.", + "engagement": engagement, + }) + } + return + } + + if engagement, err := s.deps.EngagementService.UserShareRecipe(*userId, recipeId); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{ + "status": http.StatusBadRequest, + "message": fmt.Sprintf("[ERROR] Failed to create engagement. %s", err.Error()), + }) + } else { + ctx.JSON(http.StatusOK, gin.H{ + "status": http.StatusOK, + "message": "[OK] Successfully created engagement.", + "engagement": engagement, + }) + } +} + +func (s *Server) EngagementFavoriteRecipeHandlerV2(ctx *gin.Context) { + s.withAuthenticatedUser(ctx, func(ctx *gin.Context, user *domain.User) { + recipeId, err := strconv.Atoi(ctx.Param("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 + } + + if engagement, err := s.deps.EngagementService.UserFavoriteRecipe(user.Id, recipeId); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{ + "status": http.StatusBadRequest, + "message": fmt.Sprintf("[ERROR] Failed to create engagement. %s", err.Error()), + }) + } else { + ctx.JSON(http.StatusOK, gin.H{ + "status": http.StatusOK, + "message": "[OK] Successfully created engagement.", + "engagement": engagement, + }) + } + }) +} + +func (s *Server) EngagementMakeRecipeHandlerV2(ctx *gin.Context) { + s.withAuthenticatedUser(ctx, func(ctx *gin.Context, user *domain.User) { + recipeId, err := strconv.Atoi(ctx.Param("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 + } + + if engagement, err := s.deps.EngagementService.UserMakeRecipe(user.Id, recipeId); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{ + "status": http.StatusBadRequest, + "message": fmt.Sprintf("[ERROR] Failed to create engagement. %s", err.Error()), + }) + } else { + ctx.JSON(http.StatusOK, gin.H{ + "status": http.StatusOK, + "message": "[OK] Successfully created engagement.", + "engagement": engagement, + }) + } + }) +} diff --git a/internal/app/server/server.go b/internal/app/server/server.go index abbab58..798b16d 100644 --- a/internal/app/server/server.go +++ b/internal/app/server/server.go @@ -220,5 +220,10 @@ func (s *Server) Setup() *Server { ctx.JSON(http.StatusOK, gin.H{"msg": "YAY"}) }) + router_api_v2.POST("/engagement/view/:id", JwtOptionalAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.EngagementViewRecipeHandlerV2) + router_api_v2.POST("/engagement/share/:id", JwtOptionalAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.EngagementShareRecipeHandlerV2) + router_api_v2.POST("/engagement/favorite/:id", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.EngagementFavoriteRecipeHandlerV2) + router_api_v2.POST("/engagement/make/:id", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.EngagementMakeRecipeHandlerV2) + return s } diff --git a/internal/app/server/user_handler_v2.go b/internal/app/server/user_handler_v2.go index ae9f11a..9de4a9c 100644 --- a/internal/app/server/user_handler_v2.go +++ b/internal/app/server/user_handler_v2.go @@ -24,7 +24,7 @@ func (s *Server) GetAuthenicatedUserRecipesV2(ctx *gin.Context) { if err != nil { ctx.JSON(http.StatusBadRequest, gin.H{ "status": http.StatusBadRequest, - "message": fmt.Sprintf("[FAILED] Could not fetch authenticated users's recipes. %s", err.Error()), + "message": fmt.Sprintf("[ERROR] Could not fetch authenticated users's recipes. %s", err.Error()), }) return } @@ -43,7 +43,7 @@ func (s *Server) GetAuthenicatedUserFavoritesV2(ctx *gin.Context) { if err != nil { ctx.JSON(http.StatusBadRequest, gin.H{ "status": http.StatusBadRequest, - "message": fmt.Sprintf("[FAILED] Could not fetch authenticated users's favorites. %s", err.Error()), + "message": fmt.Sprintf("[ERROR] Could not fetch authenticated users's favorites. %s", err.Error()), }) return } @@ -62,7 +62,7 @@ func (s *Server) GetAuthenicatedUserEngagementV2(ctx *gin.Context) { if err != nil { ctx.JSON(http.StatusBadRequest, gin.H{ "status": http.StatusBadRequest, - "message": fmt.Sprintf("[FAILED] Failed to get authenticated user engagement. %s", err.Error()), + "message": fmt.Sprintf("[ERROR] Failed to get authenticated user engagement. %s", err.Error()), }) return } @@ -81,7 +81,7 @@ func (s *Server) GetAuthenicatedUserMadeRecipesV2(ctx *gin.Context) { if err != nil { ctx.JSON(http.StatusBadRequest, gin.H{ "status": http.StatusBadRequest, - "message": fmt.Sprintf("[FAILED] Failed to get authenticated user's made recipes. %s", err.Error()), + "message": fmt.Sprintf("[ERROR] Failed to get authenticated user's made recipes. %s", err.Error()), }) return } @@ -100,7 +100,7 @@ func (s *Server) GetAuthenicatedUserViewedRecipesV2(ctx *gin.Context) { if err != nil { ctx.JSON(http.StatusBadRequest, gin.H{ "status": http.StatusBadRequest, - "message": fmt.Sprintf("[FAILED] Failed to get authenticated user's viewed recipes. %s", err.Error()), + "message": fmt.Sprintf("[ERROR] Failed to get authenticated user's viewed recipes. %s", err.Error()), }) return } diff --git a/web/src/components/Spinner.tsx b/web/src/components/Spinner.tsx index 14b509b..e8c536e 100644 --- a/web/src/components/Spinner.tsx +++ b/web/src/components/Spinner.tsx @@ -6,7 +6,7 @@ export default function Spinner({ content }: SpinnerProps) { return ( <>
-

{ content }

+

{content}

); } diff --git a/web/src/components/buttons/FavoriteButton.tsx b/web/src/components/buttons/FavoriteButton.tsx index d4fcde9..cc1d39e 100644 --- a/web/src/components/buttons/FavoriteButton.tsx +++ b/web/src/components/buttons/FavoriteButton.tsx @@ -1,4 +1,8 @@ -import { useEffect, useState } from "react"; +import { use, useEffect, useState } from "react"; +import { AuthContext } from "../../context/AuthContext"; +import { useNavigate } from "react-router-dom"; +import { EngagementFavoriteRecipe } from "../../services/EngagementService"; +import { isApiError } from "../../types/api/error"; interface FavoriteButtonProps { @@ -7,11 +11,29 @@ interface FavoriteButtonProps { } export default function FavoriteButton({ favorite, id }: FavoriteButtonProps) { + // CONTEXT + const { isLoggedIn } = use(AuthContext); + + const navigate = useNavigate(); + const [_favorite, setFavorite] = useState(); - const clickHandler = () => { - // TODO: Implement action here! + const clickHandler = async () => { + // This button cannot be used if not logged in + if (!isLoggedIn) { + await navigate("/v2/web/login"); + return; + } + + if (!id) return; + + // Toggle button first, to feel fast setFavorite(!_favorite); + + const result = await EngagementFavoriteRecipe(id); + if (isApiError(result)) { + console.error(result.message); + } } useEffect(() => { @@ -23,7 +45,7 @@ export default function FavoriteButton({ favorite, id }: FavoriteButtonProps) { return _favorite ? (