From bebeb254923368073a33b680b20965450abd47e9 Mon Sep 17 00:00:00 2001 From: Hayden Hargreaves Date: Mon, 14 Jul 2025 21:30:45 -0700 Subject: [PATCH] (DB/FEAT): Implemented toggle favorite in the backend. The frontend is half wired up, just need to update the button. I also want to update the recipe methods to return the favorite status. This will follow very similar to the way I updated the tags. Another method which can be called to attach the favorite state. --- doc/TechnicalSpecification.md | 10 ++-- internal/app/handlers/engagement_handler.go | 4 +- internal/app/server/server.go | 2 +- internal/app/service/engagement_service.go | 23 +++++-- internal/domain/engagement/repository.go | 1 + internal/domain/engagement/service.go | 2 +- internal/domain/server/routes.go | 2 +- .../migrations/008_create_favorites_table.sql | 14 +++++ .../repository/engagement_repository.go | 60 +++++++++++++++++++ internal/templates/pages/recipe.templ | 2 +- internal/templates/pages/recipe_templ.go | 6 +- 11 files changed, 107 insertions(+), 19 deletions(-) create mode 100644 internal/infrastructure/database/migrations/008_create_favorites_table.sql diff --git a/doc/TechnicalSpecification.md b/doc/TechnicalSpecification.md index 8af89c5..4f03d6d 100644 --- a/doc/TechnicalSpecification.md +++ b/doc/TechnicalSpecification.md @@ -299,11 +299,11 @@ found in **OTHER** section. - [x] UserId (FK: User.Id) Serial, optional for not logged in users - [x] Created (Required) date/time stamp -- [ ] Likes: **Many-to-many** table to represent a list of recipes liked by a user. - - [ ] ID (PK) *Composite key*** - - [ ] UserId (FK: User.Id, Required) Serial - - [ ] RecipeId (FK: Recipe.Id, Required) Serial - - [ ] Created (Required) date/time stamp +- [x] Favorites: **Many-to-many** table to represent a list of recipes favorites by a user. + - [x] ID (PK) *Composite key*** + - [x] UserId (FK: User.Id, Required) Serial + - [x] RecipeId (FK: Recipe.Id, Required) Serial + - [x] Created (Required) date/time stamp - [x] Tags: Represents a single tag that can be had by many recipes. - [x] ID (PK) Serial diff --git a/internal/app/handlers/engagement_handler.go b/internal/app/handlers/engagement_handler.go index 36bcddc..d57bc6b 100644 --- a/internal/app/handlers/engagement_handler.go +++ b/internal/app/handlers/engagement_handler.go @@ -33,7 +33,7 @@ func EngagementViewRecipe(ctx *gin.Context) { } } -func EngagementLikeRecipe(ctx *gin.Context) { +func EngagementFavoriteRecipe(ctx *gin.Context) { deps := ctx.MustGet("deps").(*domain.InjectedDependencies) if !domain.IsLoggedIn(ctx) { @@ -46,7 +46,7 @@ func EngagementLikeRecipe(ctx *gin.Context) { recipeId, _ := strconv.Atoi(id) userId := ctx.MustGet("userId").(int) - if _, err := deps.EngagementService.UserLikeRecipe(userId, recipeId); err != nil { + if _, err := deps.EngagementService.UserFavoriteRecipe(userId, recipeId); err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{ "status": http.StatusInternalServerError, "message": err.Error(), diff --git a/internal/app/server/server.go b/internal/app/server/server.go index fee14fd..226aa43 100644 --- a/internal/app/server/server.go +++ b/internal/app/server/server.go @@ -188,7 +188,7 @@ func (s *Server) Setup() *Server { // Engagement endpoints router_api.POST("/engagement/view/:id", handlers.EngagementViewRecipe) - router_api.POST("/engagement/like/:id", handlers.EngagementLikeRecipe) + router_api.POST("/engagement/favorite/:id", handlers.EngagementFavoriteRecipe) router_api.POST("/engagement/make/:id", handlers.EngagementMakeRecipe) // Catch un-routed URLS diff --git a/internal/app/service/engagement_service.go b/internal/app/service/engagement_service.go index 27f0971..6aa2c8e 100644 --- a/internal/app/service/engagement_service.go +++ b/internal/app/service/engagement_service.go @@ -39,22 +39,35 @@ func (s *EngagementService) UserViewRecipe(userId, recipeId int) (domain.Engagem return s.engagementRepository.AddUserEntityEngagement(userId, recipeId, message, domain.EngagementViewed) } -// UserLikeRecipe requires a user ID and a recipe ID to create an engagement record in the database. +// UserFavoriteRecipe 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 like engagement to the // database. -func (s *EngagementService) UserLikeRecipe(userId, recipeId int) (domain.Engagement, error) { +func (s *EngagementService) UserFavoriteRecipe(userId, recipeId int) (domain.Engagement, error) { recipe, err := s.recipeRepository.GetRecipe(recipeId) if err != nil { return domain.Engagement{}, err } - message := fmt.Sprintf("Liked \"%s\"", recipe.Title) + // Update the favorites DB + liked, err := s.engagementRepository.UserFavoriteRecipeToggle(userId, recipeId) + if err != nil { + return domain.Engagement{}, err + } + + // Determine if this like is a saving or unsaving + var message string + if liked { + message = fmt.Sprintf("Saved \"%s\"", recipe.Title) + } else { + message = fmt.Sprintf("Unsaved \"%s\"", recipe.Title) + } return s.engagementRepository.AddUserEntityEngagement(userId, recipeId, message, domain.EngagementLiked) + } -// UserLikeRecipe 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 like engagement to the +// UserMakeRecipe 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) UserMakeRecipe(userId, recipeId int) (domain.Engagement, error) { recipe, err := s.recipeRepository.GetRecipe(recipeId) diff --git a/internal/domain/engagement/repository.go b/internal/domain/engagement/repository.go index 204682a..906a08e 100644 --- a/internal/domain/engagement/repository.go +++ b/internal/domain/engagement/repository.go @@ -4,4 +4,5 @@ type EngagementRepository interface { AddUserEngagement(userId int, message string, engagementType EngagementType) (Engagement, error) AddUserEntityEngagement(userId, entityId int, message string, engagementType EngagementType) (Engagement, error) GetUserEngagement(userId, limit int) ([]Engagement, error) + UserFavoriteRecipeToggle(userId, recipeId int) (bool, error) } diff --git a/internal/domain/engagement/service.go b/internal/domain/engagement/service.go index 2643e91..815ac47 100644 --- a/internal/domain/engagement/service.go +++ b/internal/domain/engagement/service.go @@ -2,7 +2,7 @@ package domain type EngagementService interface { UserViewRecipe(userId, recipeId int) (Engagement, error) - UserLikeRecipe(userId, recipeId int) (Engagement, error) + UserFavoriteRecipe(userId, recipeId int) (Engagement, error) UserMakeRecipe(userId, recipeId int) (Engagement, error) GetUserEngagement(userId, limit int) ([]Engagement, error) } diff --git a/internal/domain/server/routes.go b/internal/domain/server/routes.go index 221e1fa..a6f2fab 100644 --- a/internal/domain/server/routes.go +++ b/internal/domain/server/routes.go @@ -26,7 +26,7 @@ const API_CREATE_RECIPE = VERSION + API + "/recipe" const API_SEARCH_RECIPES = VERSION + API + "/recipe/search" const API_ENGAGEMENT_VIEW = VERSION + API + "/engagement/view/%d" -const API_ENGAGEMENT_LIKE = VERSION + API + "/engagement/like/%d" +const API_ENGAGEMENT_FAVORITE = VERSION + API + "/engagement/favorite/%d" const API_ENGAGEMENT_MAKE = VERSION + API + "/engagement/make/%d" // State prefixed routes diff --git a/internal/infrastructure/database/migrations/008_create_favorites_table.sql b/internal/infrastructure/database/migrations/008_create_favorites_table.sql new file mode 100644 index 0000000..8997128 --- /dev/null +++ b/internal/infrastructure/database/migrations/008_create_favorites_table.sql @@ -0,0 +1,14 @@ +-- Author: Hayden Hargreaves (hhargreaves2006@gmail.com) +-- Desc: Create the favorites table. +-- Date: 07/14/2025 + +BEGIN; + +CREATE TABLE IF NOT EXISTS Favorites ( + Id SERIAL PRIMARY KEY NOT NULL, + UserId INTEGER NOT NULL REFERENCES users(id), + RecipeId INTEGER NOT NULL REFERENCES recipes(id), + Created TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +COMMIT; diff --git a/internal/infrastructure/database/repository/engagement_repository.go b/internal/infrastructure/database/repository/engagement_repository.go index 50ab3a4..e869262 100644 --- a/internal/infrastructure/database/repository/engagement_repository.go +++ b/internal/infrastructure/database/repository/engagement_repository.go @@ -2,6 +2,7 @@ package repository import ( "database/sql" + "errors" "fmt" "time" @@ -153,3 +154,62 @@ func (r *EngagementRepository) GetUserEngagement(userId, limit int) ([]domain.En return engagements, err } + +// UserFavoriteRecipeToggle toggles the status of a users favorite of a recipe. If the user has already +// favorited the provided recipe, the database entry will be delete, hence removing the favorite. Otherwise, +// an entry will be created. The NEW status of the users favorite will be returned as the boolean. Any +// errors will be bubbled to the caller. +func (r *EngagementRepository) UserFavoriteRecipeToggle(userId, recipeId int) (bool, error) { + tx, err := r.db.Begin() + if err != nil { + tx.Rollback() + return false, err + } + + query := ` + SELECT id + FROM favorites + WHERE userid = $1 AND recipeid = $2 + ` + + var id int + + err = tx.QueryRow(query, userId, recipeId).Scan(&id) + if err != nil { + if !errors.Is(err, sql.ErrNoRows) { + tx.Rollback() + return false, fmt.Errorf("Failed to get recipe favorite. %s", err.Error()) + } + } + + // Means we should create + var success bool + if id == 0 { + createQuery := "INSERT INTO favorites (userid, recipeid, created) VALUES ($1, $2, $3);" + + if result, err := tx.Exec(createQuery, userId, recipeId, time.Now()); err != nil { + tx.Rollback() + return false, fmt.Errorf("Failed to create recipe favorite. %s", err.Error()) + } else { + rows, _ := result.RowsAffected() + success = rows == 1 + } + } else { + deleteQuery := "DELETE FROM favorites WHERE id = $1;" + + if result, err := tx.Exec(deleteQuery, id); err != nil { + tx.Rollback() + return false, fmt.Errorf("Failed to remove recipe favorite. %s", err.Error()) + } else { + rows, _ := result.RowsAffected() + success = rows == 1 + } + } + + if err := tx.Commit(); err != nil { + tx.Rollback() + return false, err + } + + return success, nil +} diff --git a/internal/templates/pages/recipe.templ b/internal/templates/pages/recipe.templ index 37e51f5..1d8e17f 100644 --- a/internal/templates/pages/recipe.templ +++ b/internal/templates/pages/recipe.templ @@ -228,7 +228,7 @@ templ madeButton(id int) { hx-trigger="click" hx-swap="none" id="make-button" - hx-on:click="makeButtonHandler();" + hx-on:click="makeButtonHandler();" class="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" >