(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.
This commit is contained in:
Hayden Hargreaves 2025-07-14 21:30:45 -07:00
parent d29426290d
commit bebeb25492
11 changed files with 107 additions and 19 deletions

View File

@ -299,11 +299,11 @@ found in **OTHER** section.
- [x] UserId (FK: User.Id) Serial, optional for not logged in users - [x] UserId (FK: User.Id) Serial, optional for not logged in users
- [x] Created (Required) date/time stamp - [x] Created (Required) date/time stamp
- [ ] Likes: **Many-to-many** table to represent a list of recipes liked by a user. - [x] Favorites: **Many-to-many** table to represent a list of recipes favorites by a user.
- [ ] ID (PK) *Composite key*** - [x] ID (PK) *Composite key***
- [ ] UserId (FK: User.Id, Required) Serial - [x] UserId (FK: User.Id, Required) Serial
- [ ] RecipeId (FK: Recipe.Id, Required) Serial - [x] RecipeId (FK: Recipe.Id, Required) Serial
- [ ] Created (Required) date/time stamp - [x] Created (Required) date/time stamp
- [x] Tags: Represents a single tag that can be had by many recipes. - [x] Tags: Represents a single tag that can be had by many recipes.
- [x] ID (PK) Serial - [x] ID (PK) Serial

View File

@ -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) deps := ctx.MustGet("deps").(*domain.InjectedDependencies)
if !domain.IsLoggedIn(ctx) { if !domain.IsLoggedIn(ctx) {
@ -46,7 +46,7 @@ func EngagementLikeRecipe(ctx *gin.Context) {
recipeId, _ := strconv.Atoi(id) recipeId, _ := strconv.Atoi(id)
userId := ctx.MustGet("userId").(int) 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{ ctx.JSON(http.StatusInternalServerError, gin.H{
"status": http.StatusInternalServerError, "status": http.StatusInternalServerError,
"message": err.Error(), "message": err.Error(),

View File

@ -188,7 +188,7 @@ func (s *Server) Setup() *Server {
// Engagement endpoints // Engagement endpoints
router_api.POST("/engagement/view/:id", handlers.EngagementViewRecipe) 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) router_api.POST("/engagement/make/:id", handlers.EngagementMakeRecipe)
// Catch un-routed URLS // Catch un-routed URLS

View File

@ -39,22 +39,35 @@ func (s *EngagementService) UserViewRecipe(userId, recipeId int) (domain.Engagem
return s.engagementRepository.AddUserEntityEngagement(userId, recipeId, message, domain.EngagementViewed) 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 // A message will be generated using the recipe data and then used to add a like engagement to the
// database. // 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) recipe, err := s.recipeRepository.GetRecipe(recipeId)
if err != nil { if err != nil {
return domain.Engagement{}, err return domain.Engagement{}, err
} }
message := fmt.Sprintf("Liked \"%s\"", recipe.Title) // Update the favorites DB
liked, err := s.engagementRepository.UserFavoriteRecipeToggle(userId, recipeId)
return s.engagementRepository.AddUserEntityEngagement(userId, recipeId, message, domain.EngagementLiked) if err != nil {
return domain.Engagement{}, err
} }
// UserLikeRecipe requires a user ID and a recipe ID to create an engagement record in the database. // Determine if this like is a saving or unsaving
// A message will be generated using the recipe data and then used to add a like engagement to the 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)
}
// 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. // database.
func (s *EngagementService) UserMakeRecipe(userId, recipeId int) (domain.Engagement, error) { func (s *EngagementService) UserMakeRecipe(userId, recipeId int) (domain.Engagement, error) {
recipe, err := s.recipeRepository.GetRecipe(recipeId) recipe, err := s.recipeRepository.GetRecipe(recipeId)

View File

@ -4,4 +4,5 @@ type EngagementRepository interface {
AddUserEngagement(userId int, message string, engagementType EngagementType) (Engagement, error) AddUserEngagement(userId int, message string, engagementType EngagementType) (Engagement, error)
AddUserEntityEngagement(userId, entityId int, message string, engagementType EngagementType) (Engagement, error) AddUserEntityEngagement(userId, entityId int, message string, engagementType EngagementType) (Engagement, error)
GetUserEngagement(userId, limit int) ([]Engagement, error) GetUserEngagement(userId, limit int) ([]Engagement, error)
UserFavoriteRecipeToggle(userId, recipeId int) (bool, error)
} }

View File

@ -2,7 +2,7 @@ package domain
type EngagementService interface { type EngagementService interface {
UserViewRecipe(userId, recipeId int) (Engagement, error) UserViewRecipe(userId, recipeId int) (Engagement, error)
UserLikeRecipe(userId, recipeId int) (Engagement, error) UserFavoriteRecipe(userId, recipeId int) (Engagement, error)
UserMakeRecipe(userId, recipeId int) (Engagement, error) UserMakeRecipe(userId, recipeId int) (Engagement, error)
GetUserEngagement(userId, limit int) ([]Engagement, error) GetUserEngagement(userId, limit int) ([]Engagement, error)
} }

View File

@ -26,7 +26,7 @@ const API_CREATE_RECIPE = VERSION + API + "/recipe"
const API_SEARCH_RECIPES = VERSION + API + "/recipe/search" const API_SEARCH_RECIPES = VERSION + API + "/recipe/search"
const API_ENGAGEMENT_VIEW = VERSION + API + "/engagement/view/%d" 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" const API_ENGAGEMENT_MAKE = VERSION + API + "/engagement/make/%d"
// State prefixed routes // State prefixed routes

View File

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

View File

@ -2,6 +2,7 @@ package repository
import ( import (
"database/sql" "database/sql"
"errors"
"fmt" "fmt"
"time" "time"
@ -153,3 +154,62 @@ func (r *EngagementRepository) GetUserEngagement(userId, limit int) ([]domain.En
return engagements, err 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
}

View File

@ -584,7 +584,7 @@ func favoriteButton(favorited bool, id int) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var22 string var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf(domainServer.API_ENGAGEMENT_LIKE, id)) templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf(domainServer.API_ENGAGEMENT_FAVORITE, id))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/pages/recipe.templ`, Line: 189, Col: 62} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/pages/recipe.templ`, Line: 189, Col: 62}
} }
@ -602,7 +602,7 @@ func favoriteButton(favorited bool, id int) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var23 string var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf(domainServer.API_ENGAGEMENT_LIKE, id)) templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf(domainServer.API_ENGAGEMENT_FAVORITE, id))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/pages/recipe.templ`, Line: 204, Col: 62} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/pages/recipe.templ`, Line: 204, Col: 62}
} }