diff --git a/internal/app/handlers/page_handler.go b/internal/app/handlers/page_handler.go index 26c43a3..e9e0f7a 100644 --- a/internal/app/handlers/page_handler.go +++ b/internal/app/handlers/page_handler.go @@ -9,9 +9,11 @@ import ( "github.com/a-h/templ" "github.com/gin-gonic/gin" domainRecipe "github.com/haydenhargreaves/Potion/internal/domain/recipe" + domain "github.com/haydenhargreaves/Potion/internal/domain/server" domainServer "github.com/haydenhargreaves/Potion/internal/domain/server" layouts "github.com/haydenhargreaves/Potion/internal/templates/layouts" pages "github.com/haydenhargreaves/Potion/internal/templates/pages" + templates "github.com/haydenhargreaves/Potion/internal/templates/pages" ) func LoginPage(ctx *gin.Context) { @@ -22,8 +24,36 @@ func LoginPage(ctx *gin.Context) { } func HomePage(ctx *gin.Context) { + deps := ctx.MustGet("deps").(*domainServer.InjectedDependencies) + + loggedIn := domain.IsLoggedIn(ctx) + + var page templ.Component + if loggedIn { + userId := ctx.MustGet("userId").(int) + madeRecipes, err := deps.RecipeService.GetUserMadeRecipes(userId, 6) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{ + "status": http.StatusInternalServerError, + "message": fmt.Sprintf("Error getting made recipes. %s\n", err.Error()), + }) + return + } + viewedRecipes, err := deps.RecipeService.GetUserViewedRecipes(userId, 6) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{ + "status": http.StatusInternalServerError, + "message": fmt.Sprintf("Error getting made recipes. %s\n", err.Error()), + }) + return + } + + page = templates.HomePage(true, viewedRecipes, madeRecipes) + } else { + page = templates.HomePage(false, nil, nil) + } + title := "Potion - Home" - page := pages.HomePage() ctx.HTML(http.StatusOK, "", layouts.AppLayout(title, page)) } diff --git a/internal/app/server/server.go b/internal/app/server/server.go index 325582a..0084d93 100644 --- a/internal/app/server/server.go +++ b/internal/app/server/server.go @@ -118,7 +118,7 @@ func (s *Server) Setup() *Server { engagementRepo := repository.NewEngagementRepository(s.DB) userService := service.NewUserService(userRepo) authService := service.NewAuthService(userRepo, jwtSecret) - recipeService := service.NewRecipeService(recipeRepo) + recipeService := service.NewRecipeService(recipeRepo, engagementRepo) engagementService := service.NewEngagementService(engagementRepo, recipeRepo) deps := &domain.InjectedDependencies{ @@ -188,6 +188,22 @@ func (s *Server) Setup() *Server { router_api.GET("/user/recipes", handlers.GetUserRecipes) router_api.GET("/user/favorites", handlers.GetUserFavoriteRecipes) + router_api.GET("/user/temp", func(ctx *gin.Context) { + recipes, err := recipeService.GetUserMadeRecipes(3, 6) + + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{ + "recipes": recipes, + "error": err.Error(), + }) + } else { + ctx.JSON(http.StatusBadRequest, gin.H{ + "recipes": recipes, + "error": "", + }) + } + }) + // Engagement endpoints router_api.POST("/engagement/view/:id", handlers.EngagementViewRecipe) router_api.POST("/engagement/share/:id", handlers.EngagementShareRecipe) diff --git a/internal/app/service/recipe_service.go b/internal/app/service/recipe_service.go index c0ceb3f..e9d48d6 100644 --- a/internal/app/service/recipe_service.go +++ b/internal/app/service/recipe_service.go @@ -9,13 +9,15 @@ import ( "time" "github.com/gin-gonic/gin" + domainEngagement "github.com/haydenhargreaves/Potion/internal/domain/engagement" domain "github.com/haydenhargreaves/Potion/internal/domain/recipe" domainServer "github.com/haydenhargreaves/Potion/internal/domain/server" ) // RecipeService implements the domain.RecipeService defined in the domain module. type RecipeService struct { - recipeRepository domain.RecipeRepository + recipeRepository domain.RecipeRepository + engagementRepository domainEngagement.EngagementRepository } // Compile-time check to ensure the RecipeService implements domain.RecipeService @@ -23,8 +25,11 @@ var _ domain.RecipeService = (*RecipeService)(nil) // NewRecipeService creates a user service object which can be passed into the context. The service // requires a recipe repository which it will use to hit the database when needed. -func NewRecipeService(recipeRepository domain.RecipeRepository) domain.RecipeService { - return &RecipeService{recipeRepository: recipeRepository} +func NewRecipeService(recipeRepository domain.RecipeRepository, engagementRepository domainEngagement.EngagementRepository) domain.RecipeService { + return &RecipeService{ + recipeRepository: recipeRepository, + engagementRepository: engagementRepository, + } } // CreateRecipe creates a recipe in the database using the recipe repository. This function requires @@ -168,3 +173,31 @@ func (s *RecipeService) GetUserRecipes(id int) ([]domain.Recipe, error) { func (s *RecipeService) GetUserFavoriteRecipes(id int) ([]domain.Recipe, error) { return s.recipeRepository.GetUserFavoriteRecipes(id) } + +func (s *RecipeService) GetUserViewedRecipes(userId, limit int) ([]domain.Recipe, error) { + engagement, err := s.engagementRepository.GetUserEngagementFiltered(userId, limit, domainEngagement.EngagementViewed) + if err != nil { + return nil, err + } + + ids := make([]int, len(engagement)) + for i, eng := range engagement { + ids[i] = eng.Entity + } + + return s.recipeRepository.GetRecipes(ids, &userId) +} + +func (s *RecipeService) GetUserMadeRecipes(userId, limit int) ([]domain.Recipe, error) { + engagement, err := s.engagementRepository.GetUserEngagementFiltered(userId, limit, domainEngagement.EngagementMade) + if err != nil { + return nil, err + } + + ids := make([]int, len(engagement)) + for i, eng := range engagement { + ids[i] = eng.Entity + } + + return s.recipeRepository.GetRecipes(ids, &userId) +} diff --git a/internal/domain/engagement/repository.go b/internal/domain/engagement/repository.go index 294b794..93ac969 100644 --- a/internal/domain/engagement/repository.go +++ b/internal/domain/engagement/repository.go @@ -6,5 +6,6 @@ type EngagementRepository interface { AddEngagement(message string, engagementType EngagementType) (Engagement, error) AddEntityEngagement(entityId int, message string, engagementType EngagementType) (Engagement, error) GetUserEngagement(userId, limit int) ([]Engagement, error) + GetUserEngagementFiltered(userId, limit int, engagementType EngagementType) ([]Engagement, error) UserFavoriteRecipeToggle(userId, recipeId int) (bool, error) } diff --git a/internal/domain/recipe/repository.go b/internal/domain/recipe/repository.go index f0c69ad..761aa2e 100644 --- a/internal/domain/recipe/repository.go +++ b/internal/domain/recipe/repository.go @@ -3,6 +3,7 @@ package domain type RecipeRepository interface { CreateRecipe(recipe *Recipe) error GetRecipe(id int, userId *int) (*Recipe, error) + GetRecipes(ids []int, userId *int) ([]Recipe, error) SearchRecipes(filters SearchFilters, userId *int, favorites bool) ([]Recipe, error) CreateRecipeTags(recipe Recipe, tags []string) error GetUserRecipes(id int) ([]Recipe, error) diff --git a/internal/domain/recipe/service.go b/internal/domain/recipe/service.go index 51d4bd6..373d417 100644 --- a/internal/domain/recipe/service.go +++ b/internal/domain/recipe/service.go @@ -8,4 +8,6 @@ type RecipeService interface { SearchRecipes(filters SearchFilters, userId *int, favorites bool) ([]Recipe, error) GetUserRecipes(id int) ([]Recipe, error) GetUserFavoriteRecipes(id int) ([]Recipe, error) + GetUserViewedRecipes(userId, limit int) ([]Recipe, error) + GetUserMadeRecipes(userId, limit int) ([]Recipe, error) } diff --git a/internal/infrastructure/database/repository/engagement_repository.go b/internal/infrastructure/database/repository/engagement_repository.go index 16cdc9b..90e8cac 100644 --- a/internal/infrastructure/database/repository/engagement_repository.go +++ b/internal/infrastructure/database/repository/engagement_repository.go @@ -295,6 +295,70 @@ func (r *EngagementRepository) GetUserEngagement(userId, limit int) ([]domain.En return engagements, err } +// GetUserEngagementFiltered returns a list of the users most recent engagement entries of a provided +// type. The number of records is determined by the limit passed into this function. The results are +// sorted, newest-to-oldest. Only results of the provided engagementType will be returned. +func (r *EngagementRepository) GetUserEngagementFiltered(userId, limit int, engagementType domain.EngagementType) ([]domain.Engagement, error) { + tx, err := r.db.Begin() + if err != nil { + tx.Rollback() + return []domain.Engagement{}, err + } + + query := ` + SELECT id, type, message, entity, userid, created + FROM ( + SELECT + *, + ROW_NUMBER() OVER (PARTITION BY entity ORDER BY created DESC) as rn + FROM Engagements + WHERE Userid = $1 AND type = $2 + ) AS subquery + WHERE rn = 1 + ORDER BY created DESC + LIMIT $3; + ` + + rows, err := tx.Query(query, userId, engagementType, limit) + if err != nil { + tx.Rollback() + return []domain.Engagement{}, fmt.Errorf("Failed to get user engagements. %s", err.Error()) + } + defer rows.Close() + + var engagements []domain.Engagement + for rows.Next() { + var engagement domain.Engagement + var engUserId sql.NullInt32 + if err := rows.Scan( + &engagement.Id, + &engagement.Type, + &engagement.Message, + &engagement.Entity, + &engUserId, + &engagement.Created, + ); err != nil { + tx.Rollback() + return []domain.Engagement{}, fmt.Errorf("Failed to scan user engagement. %s", err.Error()) + } + + // Add user if valid + if engUserId.Valid { + engagement.UserId = int(engUserId.Int32) + } + + engagements = append(engagements, engagement) + } + + if err := tx.Commit(); err != nil { + tx.Rollback() + return []domain.Engagement{}, 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 diff --git a/internal/infrastructure/database/repository/recipe_repository.go b/internal/infrastructure/database/repository/recipe_repository.go index eb3c542..f043102 100644 --- a/internal/infrastructure/database/repository/recipe_repository.go +++ b/internal/infrastructure/database/repository/recipe_repository.go @@ -174,6 +174,105 @@ func (r *RecipeRepository) GetRecipe(id int, userId *int) (*domain.Recipe, error return &recipe, nil } +// GetRecipes gets a list of recipes from the database via their 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) GetRecipes(ids []int, userId *int) ([]domain.Recipe, error) { + tx, err := r.db.Begin() + if err != nil { + tx.Rollback() + return nil, err + } + + query := ` + SELECT + id, title, description, instructions, serves, difficulty, duration, category, ingredients, userid, modified, created + FROM recipes + WHERE id = ANY($1) + ORDER BY array_position($1, id); + ` + + var recipes []domain.Recipe + + rows, err := tx.Query(query, pq.Array(ids)) + if err != nil { + tx.Rollback() + return nil, fmt.Errorf("Failed to get recipes. %s", err.Error()) + } + defer rows.Close() + + for rows.Next() { + var recipe domain.Recipe + var durationBytes []byte + var ingredientBytes []byte + + if err := rows.Scan( + &recipe.Id, + &recipe.Title, + &recipe.Description, + pq.Array(&recipe.Instructions), + &recipe.Serves, + &recipe.Difficulty, + &durationBytes, + &recipe.Category, + &ingredientBytes, + &recipe.UserId, + &recipe.Modified, + &recipe.Created, + ); err != nil { + return nil, fmt.Errorf("Failed to scan recipe from database: %s", err.Error()) + } + + // Parse duration + if len(durationBytes) > 0 { + var duration domain.RecipeDuration + if err := json.Unmarshal(durationBytes, &duration); err != nil { + return nil, fmt.Errorf("Failed to parse duration from database: %s", err.Error()) + } + + recipe.Duration = duration + } else { + recipe.Duration = domain.RecipeDuration{} + } + + // Parse ingredient + if len(ingredientBytes) > 0 { + var ingredients []domain.RecipeIngredient + if err := json.Unmarshal(ingredientBytes, &ingredients); err != nil { + return nil, fmt.Errorf("Failed to parse ingredients from database: %s", err.Error()) + } + + recipe.Ingredients = ingredients + } else { + recipe.Ingredients = []domain.RecipeIngredient{} + } + + // Add tags + 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 + } + + recipes = append(recipes, recipe) + } + + if err := tx.Commit(); err != nil { + tx.Rollback() + return nil, err + } + + return recipes, nil +} + // isBitActive returns true when the bit at pos (0 indexed) is true. func isBitActive(bits, pos int) bool { return (bits>>pos)&1 == 1 diff --git a/internal/templates/components/cards.templ b/internal/templates/components/cards.templ index d31e7a3..58a8f20 100644 --- a/internal/templates/components/cards.templ +++ b/internal/templates/components/cards.templ @@ -1,72 +1,90 @@ package components -templ likeButton(liked bool) { - +import "fmt" +import "github.com/haydenhargreaves/Potion/internal/domain/recipe" +import domainServer "github.com/haydenhargreaves/Potion/internal/domain/server" + +templ likeButton() { + } -templ RecipeCardSmall(name, meal, author string, liked bool) { -
- { author } -
-- { meal } -
- @likeButton(liked) -+ Serves { recipe.Serves } +
++ { recipe.Category } - { recipe.Duration.Total } mins +
+ if recipe.Favorite { + @likeButton() + } +{ content }
+ +- Hayden Hargreaves -
-- Avocado toast is a delicious and simple breakfast, snack or light meal! Learn how to - make the BEST avocado toast with this recipe, plus fun variations. - Avocado toast is a delicious and simple breakfast, snack or light meal! Learn how to - make the BEST avocado toast with this recipe, plus fun variations. - Avocado toast is a delicious and simple breakfast, snack or light meal! Learn how to - make the BEST avocado toast with this recipe, plus fun variations. -
-- Breakfast - 15 min -
- @likeButton(liked) -+ Hayden Hargreaves +
++ Avocado toast is a delicious and simple breakfast, snack or light meal! Learn how to + make the BEST avocado toast with this recipe, plus fun variations. + Avocado toast is a delicious and simple breakfast, snack or light meal! Learn how to + make the BEST avocado toast with this recipe, plus fun variations. + Avocado toast is a delicious and simple breakfast, snack or light meal! Learn how to + make the BEST avocado toast with this recipe, plus fun variations. +
++ Breakfast - 15 min +
+ if liked { + @likeButton() + } +") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
Serves ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var4 string - templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(author) + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(recipe.Serves) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/components/cards.templ`, Line: 28, Col: 14} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/components/cards.templ`, Line: 26, Col: 26} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var5 string - templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(meal) + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(recipe.Category) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/components/cards.templ`, Line: 32, Col: 14} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/components/cards.templ`, Line: 30, Col: 22} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, " - ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = likeButton(liked).Render(ctx, templ_7745c5c3_Buffer) + var templ_7745c5c3_Var6 string + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(recipe.Duration.Total) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/components/cards.templ`, Line: 30, Col: 50} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "Hayden Hargreaves
Avocado toast is a delicious and simple breakfast, snack or light meal! Learn how to make the BEST avocado toast with this recipe, plus fun variations. Avocado toast is a delicious and simple breakfast, snack or light meal! Learn how to make the BEST avocado toast with this recipe, plus fun variations. Avocado toast is a delicious and simple breakfast, snack or light meal! Learn how to make the BEST avocado toast with this recipe, plus fun variations.
Breakfast - 15 min
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "Hayden Hargreaves
Avocado toast is a delicious and simple breakfast, snack or light meal! Learn how to make the BEST avocado toast with this recipe, plus fun variations. Avocado toast is a delicious and simple breakfast, snack or light meal! Learn how to make the BEST avocado toast with this recipe, plus fun variations. Avocado toast is a delicious and simple breakfast, snack or light meal! Learn how to make the BEST avocado toast with this recipe, plus fun variations.
Breakfast - 15 min
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = likeButton(liked).Render(ctx, templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err + if liked { + templ_7745c5c3_Err = likeButton().Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "