(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).
This commit is contained in:
Hayden Hargreaves 2025-07-15 19:17:41 -07:00
parent bebeb25492
commit 79bee1cde7
10 changed files with 96 additions and 92 deletions

View File

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

View File

@ -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,7 +43,7 @@ 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
}
@ -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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -186,10 +186,10 @@ templ tagListItem(content string) {
templ favoriteButton(favorited bool, id int) {
if favorited {
<button
hx-post={ fmt.Sprintf(domainServer.API_ENGAGEMENT_LIKE, id) }
hx-post={ fmt.Sprintf(domainServer.API_ENGAGEMENT_FAVORITE, id) }
hx-trigger="click"
hx-swap="none"
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-blue-300 bg-blue-50 text-gray-800 px-6 py-3 flex-grow hover:bg-blue-100 hover:border-blue-500 duration-300"
>
<svg class="h-6 text-red-500" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
@ -201,7 +201,7 @@ templ favoriteButton(favorited bool, id int) {
</button>
} else {
<button
hx-post={ fmt.Sprintf(domainServer.API_ENGAGEMENT_LIKE, id) }
hx-post={ fmt.Sprintf(domainServer.API_ENGAGEMENT_FAVORITE, id) }
hx-trigger="click"
hx-swap="none"
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"
@ -289,7 +289,7 @@ templ RecipePage(recipe domain.Recipe, user domainUser.User) {
<p class="text-sm mb-2 text-gray-700">Category: { recipe.Category }</p>
</div>
@metadataSection(recipe)
@buttonSection(false, recipe.Id)
@buttonSection(recipe.Favorite, recipe.Id)
<div class="px-4 py-8 md:px-8">
<h3 class="text-xl text-gray-800 font-semibold mb-2">About this recipe</h3>
<p class="text-gray-700">{ recipe.Description }</p>

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
@ -586,13 +586,13 @@ func favoriteButton(favorited bool, id int) templ.Component {
var templ_7745c5c3_Var22 string
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}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/pages/recipe.templ`, Line: 189, Col: 66}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\" hx-trigger=\"click\" hx-swap=\"none\" 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 class=\"h-6 text-red-500\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M2 9.1371C2 14 6.01943 16.5914 8.96173 18.9109C10 19.7294 11 20.5 12 20.5C13 20.5 14 19.7294 15.0383 18.9109C17.9806 16.5914 22 14 22 9.1371C22 4.27416 16.4998 0.825464 12 5.50063C7.50016 0.825464 2 4.27416 2 9.1371Z\" fill=\"currentColor\"></path></svg> Unfavorite</button>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\" hx-trigger=\"click\" hx-swap=\"none\" class=\"flex items-center justify-center gap-x-1 rounded-lg border border-blue-300 bg-blue-50 text-gray-800 px-6 py-3 flex-grow hover:bg-blue-100 hover:border-blue-500 duration-300\"><svg class=\"h-6 text-red-500\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M2 9.1371C2 14 6.01943 16.5914 8.96173 18.9109C10 19.7294 11 20.5 12 20.5C13 20.5 14 19.7294 15.0383 18.9109C17.9806 16.5914 22 14 22 9.1371C22 4.27416 16.4998 0.825464 12 5.50063C7.50016 0.825464 2 4.27416 2 9.1371Z\" fill=\"currentColor\"></path></svg> Unfavorite</button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@ -604,7 +604,7 @@ func favoriteButton(favorited bool, id int) templ.Component {
var templ_7745c5c3_Var23 string
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}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/pages/recipe.templ`, Line: 204, Col: 66}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
if templ_7745c5c3_Err != nil {
@ -807,7 +807,7 @@ func RecipePage(recipe domain.Recipe, user domainUser.User) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = buttonSection(false, recipe.Id).Render(ctx, templ_7745c5c3_Buffer)
templ_7745c5c3_Err = buttonSection(recipe.Favorite, recipe.Id).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}

View File

@ -9,7 +9,6 @@
monospace;
--color-red-100: oklch(93.6% 0.032 17.717);
--color-red-500: oklch(63.7% 0.237 25.331);
--color-red-600: oklch(57.7% 0.245 27.325);
--color-green-300: oklch(87.1% 0.15 154.449);
--color-green-500: oklch(72.3% 0.219 149.579);
--color-blue-50: oklch(97% 0.014 254.604);
@ -240,9 +239,6 @@
.static {
position: static;
}
.top-1 {
top: calc(var(--spacing) * 1);
}
.top-1\/2 {
top: calc(1/2 * 100%);
}
@ -252,9 +248,6 @@
.left-0 {
left: calc(var(--spacing) * 0);
}
.left-1 {
left: calc(var(--spacing) * 1);
}
.left-1\/2 {
left: calc(1/2 * 100%);
}
@ -426,18 +419,12 @@
.min-h-screen {
min-height: 100vh;
}
.w-1 {
width: calc(var(--spacing) * 1);
}
.w-1\/3 {
width: calc(1/3 * 100%);
}
.w-1\/4 {
width: calc(1/4 * 100%);
}
.w-3 {
width: calc(var(--spacing) * 3);
}
.w-3\/4 {
width: calc(3/4 * 100%);
}
@ -450,9 +437,6 @@
.w-5 {
width: calc(var(--spacing) * 5);
}
.w-9 {
width: calc(var(--spacing) * 9);
}
.w-9\/10 {
width: calc(9/10 * 100%);
}
@ -471,9 +455,6 @@
.max-w-2xl {
max-width: var(--container-2xl);
}
.flex-shrink {
flex-shrink: 1;
}
.flex-shrink-0 {
flex-shrink: 0;
}
@ -483,21 +464,10 @@
.flex-grow {
flex-grow: 1;
}
.border-collapse {
border-collapse: collapse;
}
.-translate-x-1 {
--tw-translate-x: calc(var(--spacing) * -1);
translate: var(--tw-translate-x) var(--tw-translate-y);
}
.-translate-x-1\/2 {
--tw-translate-x: calc(calc(1/2 * 100%) * -1);
translate: var(--tw-translate-x) var(--tw-translate-y);
}
.-translate-y-1 {
--tw-translate-y: calc(var(--spacing) * -1);
translate: var(--tw-translate-x) var(--tw-translate-y);
}
.-translate-y-1\/2 {
--tw-translate-y: calc(calc(1/2 * 100%) * -1);
translate: var(--tw-translate-x) var(--tw-translate-y);
@ -510,15 +480,9 @@
--tw-scale-y: 50%;
scale: var(--tw-scale-x) var(--tw-scale-y);
}
.transform {
transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,);
}
.cursor-pointer {
cursor: pointer;
}
.resize {
resize: both;
}
.resize-none {
resize: none;
}
@ -657,9 +621,6 @@
.border-gray-300 {
border-color: var(--color-gray-300);
}
.border-green-300 {
border-color: var(--color-green-300);
}
.border-green-500 {
border-color: var(--color-green-500);
}
@ -669,9 +630,6 @@
.border-white {
border-color: var(--color-white);
}
.bg-\[\#f8f8f8\] {
background-color: #f8f8f8;
}
.bg-black {
background-color: var(--color-black);
}
@ -1132,6 +1090,20 @@
}
}
}
.hover\:border-blue-500 {
&:hover {
@media (hover: hover) {
border-color: var(--color-blue-500);
}
}
}
.hover\:bg-blue-100 {
&:hover {
@media (hover: hover) {
background-color: var(--color-blue-100);
}
}
}
.hover\:bg-blue-200 {
&:hover {
@media (hover: hover) {
@ -1537,26 +1509,6 @@
inherits: false;
initial-value: 1;
}
@property --tw-rotate-x {
syntax: "*";
inherits: false;
}
@property --tw-rotate-y {
syntax: "*";
inherits: false;
}
@property --tw-rotate-z {
syntax: "*";
inherits: false;
}
@property --tw-skew-x {
syntax: "*";
inherits: false;
}
@property --tw-skew-y {
syntax: "*";
inherits: false;
}
@property --tw-border-style {
syntax: "*";
inherits: false;
@ -1766,11 +1718,6 @@
--tw-scale-x: 1;
--tw-scale-y: 1;
--tw-scale-z: 1;
--tw-rotate-x: initial;
--tw-rotate-y: initial;
--tw-rotate-z: initial;
--tw-skew-x: initial;
--tw-skew-y: initial;
--tw-border-style: solid;
--tw-gradient-position: initial;
--tw-gradient-from: #0000;