From 79bee1cde715ebd3bcd6967d660798f52f64de58 Mon Sep 17 00:00:00 2001
From: Hayden Hargreaves
Date: Tue, 15 Jul 2025 19:17:41 -0700
Subject: [PATCH] (FEAT): Updated recipe repo to include recipe favorite
status.
This means we need to pass the user id into the various methods that
call it. But, since it is a pointer, we can use nil if we don't have a
user to check with (this is noted in the service).
---
internal/app/handlers/page_handler.go | 11 ++-
internal/app/service/engagement_service.go | 10 +--
internal/app/service/recipe_service.go | 4 +-
internal/domain/recipe/recipe.go | 4 +-
internal/domain/recipe/repository.go | 3 +-
internal/domain/recipe/service.go | 2 +-
.../database/repository/recipe_repository.go | 55 ++++++++++++-
internal/templates/pages/recipe.templ | 8 +-
internal/templates/pages/recipe_templ.go | 10 +--
web/static/css/tailwind.css | 81 ++++---------------
10 files changed, 96 insertions(+), 92 deletions(-)
diff --git a/internal/app/handlers/page_handler.go b/internal/app/handlers/page_handler.go
index a11959f..b0e9f16 100644
--- a/internal/app/handlers/page_handler.go
+++ b/internal/app/handlers/page_handler.go
@@ -104,15 +104,22 @@ func RecipePage(ctx *gin.Context) {
return
}
+ // Get signed in user, if they exist
+ var userId *int = nil
+ if domainServer.IsLoggedIn(ctx) {
+ storeId := ctx.MustGet("userId").(int)
+ userId = &storeId
+ }
+
// Get recipe
- recipe, err := deps.RecipeService.GetRecipe(parsed)
+ recipe, err := deps.RecipeService.GetRecipe(parsed, userId)
if err != nil {
fmt.Printf("ERROR: %s\n", err.Error())
ctx.JSON(400, err.Error())
return
}
- // Get user
+ // Get user (owner)
user, err := deps.UserService.GetUser(recipe.UserId)
if err != nil {
fmt.Printf("ERROR: %s\n", err.Error())
diff --git a/internal/app/service/engagement_service.go b/internal/app/service/engagement_service.go
index 6aa2c8e..362b9ad 100644
--- a/internal/app/service/engagement_service.go
+++ b/internal/app/service/engagement_service.go
@@ -29,7 +29,7 @@ func NewEngagementService(engagementRepository domain.EngagementRepository, reci
// A message will be generated using the recipe data and then used to add a view engagement to the
// database.
func (s *EngagementService) UserViewRecipe(userId, recipeId int) (domain.Engagement, error) {
- recipe, err := s.recipeRepository.GetRecipe(recipeId)
+ recipe, err := s.recipeRepository.GetRecipe(recipeId, &userId)
if err != nil {
return domain.Engagement{}, err
}
@@ -43,17 +43,17 @@ func (s *EngagementService) UserViewRecipe(userId, recipeId int) (domain.Engagem
// A message will be generated using the recipe data and then used to add a like engagement to the
// database.
func (s *EngagementService) UserFavoriteRecipe(userId, recipeId int) (domain.Engagement, error) {
- recipe, err := s.recipeRepository.GetRecipe(recipeId)
+ recipe, err := s.recipeRepository.GetRecipe(recipeId, &userId)
if err != nil {
return domain.Engagement{}, err
}
- // Update the favorites DB
+ // 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 {
@@ -70,7 +70,7 @@ func (s *EngagementService) UserFavoriteRecipe(userId, recipeId int) (domain.Eng
// 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)
+ recipe, err := s.recipeRepository.GetRecipe(recipeId, &userId)
if err != nil {
return domain.Engagement{}, err
}
diff --git a/internal/app/service/recipe_service.go b/internal/app/service/recipe_service.go
index 870f053..87cdae8 100644
--- a/internal/app/service/recipe_service.go
+++ b/internal/app/service/recipe_service.go
@@ -123,8 +123,8 @@ func (s *RecipeService) CreateRecipe(ctx *gin.Context) (*domain.Recipe, error) {
// GetRecipe will get a recipe via its ID. Any errors will be bubbled to the caller. Furthermore,
// if the recipe is nil, an error will be returned, so the caller does not need to check for a nil
// recipe (e.g., if the error is nil the recipe exists)
-func (s *RecipeService) GetRecipe(id int) (*domain.Recipe, error) {
- recipe, err := s.recipeRepository.GetRecipe(id)
+func (s *RecipeService) GetRecipe(id int, userId *int) (*domain.Recipe, error) {
+ recipe, err := s.recipeRepository.GetRecipe(id, userId)
if recipe == nil {
return nil, fmt.Errorf("Failed to get recipe from database. Nil result.")
diff --git a/internal/domain/recipe/recipe.go b/internal/domain/recipe/recipe.go
index 013a091..bad14a2 100644
--- a/internal/domain/recipe/recipe.go
+++ b/internal/domain/recipe/recipe.go
@@ -55,7 +55,8 @@ type RecipeIngredient struct {
// Recipe is the database model of a recipe. There is no need to map to a different model so
// this will remain in the domain. The Tags field should be loaded from the external Tags table,
-// but is still attached to this domain object.
+// but is still attached to this domain object. The Favorite field should also be loaded from
+// the external favorites table, these are user specific.
type Recipe struct {
Id int
Title string
@@ -70,6 +71,7 @@ type Recipe struct {
Modified *time.Time // Pointer to allow null
Created time.Time
Tags []Tag
+ Favorite bool // Per requesting user
}
// SearchFilters is a model which represents the required filters to complete a recipe search.
diff --git a/internal/domain/recipe/repository.go b/internal/domain/recipe/repository.go
index a5c57d9..d1a3a6e 100644
--- a/internal/domain/recipe/repository.go
+++ b/internal/domain/recipe/repository.go
@@ -2,9 +2,10 @@ package domain
type RecipeRepository interface {
CreateRecipe(recipe *Recipe) error
- GetRecipe(id int) (*Recipe, error)
+ GetRecipe(id int, userId *int) (*Recipe, error)
SearchRecipes(filters SearchFilters) ([]Recipe, error)
CreateRecipeTags(recipe Recipe, tags []string) error
GetUserRecipes(id int) ([]Recipe, error)
GetRecipeTags(recipe *Recipe) error
+ GetRecipeFavorite(recipe *Recipe, userId int) error
}
diff --git a/internal/domain/recipe/service.go b/internal/domain/recipe/service.go
index b3466f4..33d919a 100644
--- a/internal/domain/recipe/service.go
+++ b/internal/domain/recipe/service.go
@@ -4,7 +4,7 @@ import "github.com/gin-gonic/gin"
type RecipeService interface {
CreateRecipe(ctx *gin.Context) (*Recipe, error)
- GetRecipe(id int) (*Recipe, error)
+ GetRecipe(id int, userId *int) (*Recipe, error)
SearchRecipes(filters SearchFilters) ([]Recipe, error)
GetUserRecipes(id int) ([]Recipe, error)
}
diff --git a/internal/infrastructure/database/repository/recipe_repository.go b/internal/infrastructure/database/repository/recipe_repository.go
index e92739a..da5c6f7 100644
--- a/internal/infrastructure/database/repository/recipe_repository.go
+++ b/internal/infrastructure/database/repository/recipe_repository.go
@@ -92,7 +92,7 @@ func (r *RecipeRepository) CreateRecipe(recipe *domain.Recipe) error {
// GetRecipe gets a recipe from the database via its ID. The operation is wrapped in a transaction
// for added safety. The repository will not check for a nil result, instead the service will. Callers
// are responsible for protecting against double nil results. Any errors will be bubbled to the caller.
-func (r *RecipeRepository) GetRecipe(id int) (*domain.Recipe, error) {
+func (r *RecipeRepository) GetRecipe(id int, userId *int) (*domain.Recipe, error) {
tx, err := r.db.Begin()
if err != nil {
tx.Rollback()
@@ -153,7 +153,18 @@ func (r *RecipeRepository) GetRecipe(id int) (*domain.Recipe, error) {
}
// Add tags
- r.GetRecipeTags(&recipe)
+ if err := r.GetRecipeTags(&recipe); err != nil {
+ fmt.Printf("ERROR getting recipe tags. %s\n", err.Error())
+ }
+
+ // Get favorite status, if user id is provided
+ if userId != nil {
+ if err := r.GetRecipeFavorite(&recipe, *userId); err != nil {
+ fmt.Printf("ERROR getting recipe favorite status. %s\n", err.Error())
+ }
+ } else {
+ recipe.Favorite = false
+ }
if err := tx.Commit(); err != nil {
tx.Rollback()
@@ -368,7 +379,9 @@ func (r *RecipeRepository) SearchRecipes(filters domain.SearchFilters) ([]domain
}
// Add tags
- r.GetRecipeTags(&recipe)
+ if err := r.GetRecipeTags(&recipe); err != nil {
+ fmt.Printf("ERROR getting recipe tags. %s\n", err.Error())
+ }
recipes = append(recipes, recipe)
}
@@ -517,7 +530,14 @@ func (r *RecipeRepository) GetUserRecipes(id int) ([]domain.Recipe, error) {
}
// Add tags
- r.GetRecipeTags(&recipe)
+ if err := r.GetRecipeTags(&recipe); err != nil {
+ fmt.Printf("ERROR getting recipe tags. %s\n", err.Error())
+ }
+
+ // Get favorite status
+ if err := r.GetRecipeFavorite(&recipe, id); err != nil {
+ fmt.Printf("ERROR getting recipe favorite status. %s\n", err.Error())
+ }
recipes = append(recipes, recipe)
}
@@ -571,3 +591,30 @@ func (r *RecipeRepository) GetRecipeTags(recipe *domain.Recipe) error {
return nil
}
+
+// GetRecipeFavorite requires a recipe to be filled with at least an ID. This function will use the
+// ID defined in the provided recipe to fill the favorite status of the recipe, based on the provided
+// userId. The recipe is modified in place and is not returned. Any errors will be bubbled to the caller.
+func (r *RecipeRepository) GetRecipeFavorite(recipe *domain.Recipe, userId int) error {
+ tx, err := r.db.Begin()
+ if err != nil {
+ tx.Rollback()
+ return err
+ }
+
+ query := `
+ SELECT COUNT(*)
+ FROM favorites
+ WHERE recipeid = $1 AND userid = $2;
+ `
+
+ var count int
+ if err := tx.QueryRow(query, recipe.Id, userId).Scan(&count); err != nil {
+ tx.Rollback()
+ return fmt.Errorf("Failed to get recipe favorite. %s", err.Error())
+ }
+
+ recipe.Favorite = count > 0
+
+ return nil
+}
diff --git a/internal/templates/pages/recipe.templ b/internal/templates/pages/recipe.templ
index 1d8e17f..202ac5d 100644
--- a/internal/templates/pages/recipe.templ
+++ b/internal/templates/pages/recipe.templ
@@ -186,10 +186,10 @@ templ tagListItem(content string) {
templ favoriteButton(favorited bool, id int) {
if favorited {