(DB/FEAT): Began the implementation of the user engagement! #18
@ -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
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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;
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
|||||||
@ -228,7 +228,7 @@ templ madeButton(id int) {
|
|||||||
hx-trigger="click"
|
hx-trigger="click"
|
||||||
hx-swap="none"
|
hx-swap="none"
|
||||||
id="make-button"
|
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"
|
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
|
<svg
|
||||||
|
|||||||
@ -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}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user