(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] 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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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 (
"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
}

View File

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

View File

@ -1,4 +1,4 @@
// Code generated by templ - DO NOT EDIT.
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.865
package templates
@ -584,7 +584,7 @@ func favoriteButton(favorited bool, id int) templ.Component {
return templ_7745c5c3_Err
}
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 {
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
}
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 {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/pages/recipe.templ`, Line: 204, Col: 62}
}