(DB/FEAT): Began the implementation of the user engagement! #18

Merged
azpect merged 10 commits from feature/engagement into master 2025-07-15 21:53:17 -07:00
11 changed files with 107 additions and 19 deletions
Showing only changes of commit bebeb25492 - Show all commits

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)
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) 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. // 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 like engagement to the // 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

@ -1,4 +1,4 @@
// Code generated by templ - DO NOT EDIT. // Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.865 // templ: version: v0.3.865
package templates package templates
@ -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}
} }