diff --git a/doc/TechnicalSpecification.md b/doc/TechnicalSpecification.md
index dce8da4..4f03d6d 100644
--- a/doc/TechnicalSpecification.md
+++ b/doc/TechnicalSpecification.md
@@ -291,18 +291,19 @@ found in **OTHER** section.
- [x] GoogleRefreshToken () text
- [x] Created (Required) date/time stamp
-- [ ] Engagements: Represents a single engagement from a single user.
- - [ ] ID (PK) Serial
- - [ ] Message () text (Used to store any relevant notes, if needed)
- - [ ] Entity (Serial) Serial (Used to relate an entity, if needed)
- - [ ] UserId (FK: User.Id, Required) Serial
- - [ ] Created (Required) date/time stamp
+- [x] Engagements: Represents a single engagement from a single user.
+ - [x] ID (PK) Serial
+ - [x] Type (Required) E_Engagement
+ - [x] Message () text (Used to store any relevant notes, if needed)
+ - [x] Entity (Serial) Serial (Used to relate an entity, if needed)
+ - [x] UserId (FK: User.Id) Serial, optional for not logged in users
+ - [x] Created (Required) date/time stamp
-- [ ] Likes: **Many-to-many** table to represent a list of recipes liked by a user.
- - [ ] ID (PK) *Composite key***
- - [ ] UserId (FK: User.Id, Required) Serial
- - [ ] RecipeId (FK: Recipe.Id, Required) Serial
- - [ ] Created (Required) date/time stamp
+- [x] Favorites: **Many-to-many** table to represent a list of recipes favorites by a user.
+ - [x] ID (PK) *Composite key***
+ - [x] UserId (FK: User.Id, Required) Serial
+ - [x] RecipeId (FK: Recipe.Id, Required) Serial
+ - [x] Created (Required) date/time stamp
- [x] Tags: Represents a single tag that can be had by many recipes.
- [x] ID (PK) Serial
@@ -368,10 +369,10 @@ Various tables will reference these types.
- [ ] like: string
- [ ] system: string
-- [ ] E_Engagement: Type to represent a type of user engagement.
- - [ ] made: string
- - [ ] liked: string
- - [ ] viewed: string
- - [ ] shared: string
- - [ ] reviewed: string
- - [ ] rated: string
+- [x] E_Engagement: Type to represent a type of user engagement.
+ - [x] made: string
+ - [x] liked: string
+ - [x] viewed: string
+ - [x] shared: string
+ - [x] reviewed: string
+ - [x] rated: string
diff --git a/internal/app/handlers/engagement_handler.go b/internal/app/handlers/engagement_handler.go
new file mode 100644
index 0000000..1f80b7b
--- /dev/null
+++ b/internal/app/handlers/engagement_handler.go
@@ -0,0 +1,114 @@
+package handlers
+
+import (
+ "fmt"
+ "net/http"
+ "strconv"
+
+ "github.com/gin-gonic/gin"
+ domain "github.com/haydenhargreaves/Potion/internal/domain/server"
+)
+
+func EngagementViewRecipe(ctx *gin.Context) {
+ deps := ctx.MustGet("deps").(*domain.InjectedDependencies)
+ recipeId, _ := strconv.Atoi(ctx.Param("id"))
+
+ if !domain.IsLoggedIn(ctx) {
+ if _, err := deps.EngagementService.ViewRecipe(recipeId); err != nil {
+ ctx.JSON(http.StatusInternalServerError, gin.H{
+ "status": http.StatusInternalServerError,
+ "message": err.Error(),
+ })
+ } else {
+ ctx.Header("HX-Redirect", fmt.Sprintf(domain.WEB_RECIPE, recipeId))
+ ctx.Status(http.StatusOK)
+ }
+ return
+ }
+
+ userId := ctx.MustGet("userId").(int)
+
+ if _, err := deps.EngagementService.UserViewRecipe(userId, recipeId); err != nil {
+ ctx.JSON(http.StatusInternalServerError, gin.H{
+ "status": http.StatusInternalServerError,
+ "message": err.Error(),
+ })
+ } else {
+ ctx.Header("HX-Redirect", fmt.Sprintf(domain.WEB_RECIPE, recipeId))
+ ctx.Status(http.StatusOK)
+ }
+}
+
+func EngagementShareRecipe(ctx *gin.Context) {
+ deps := ctx.MustGet("deps").(*domain.InjectedDependencies)
+ recipeId, _ := strconv.Atoi(ctx.Param("id"))
+
+ if !domain.IsLoggedIn(ctx) {
+ if _, err := deps.EngagementService.ShareRecipe(recipeId); err != nil {
+ ctx.JSON(http.StatusInternalServerError, gin.H{
+ "status": http.StatusInternalServerError,
+ "message": err.Error(),
+ })
+ } else {
+ ctx.Status(http.StatusNoContent)
+ }
+ return
+ }
+
+ userId := ctx.MustGet("userId").(int)
+
+ if _, err := deps.EngagementService.UserShareRecipe(userId, recipeId); err != nil {
+ ctx.JSON(http.StatusInternalServerError, gin.H{
+ "status": http.StatusInternalServerError,
+ "message": err.Error(),
+ })
+ } else {
+ ctx.Status(http.StatusNoContent)
+ }
+}
+
+func EngagementFavoriteRecipe(ctx *gin.Context) {
+ deps := ctx.MustGet("deps").(*domain.InjectedDependencies)
+
+ if !domain.IsLoggedIn(ctx) {
+ ctx.Header("HX-Redirect", domain.WEB_LOGIN)
+ ctx.Status(http.StatusOK)
+ return
+ }
+
+ id := ctx.Param("id")
+ recipeId, _ := strconv.Atoi(id)
+ userId := ctx.MustGet("userId").(int)
+
+ if _, err := deps.EngagementService.UserFavoriteRecipe(userId, recipeId); err != nil {
+ ctx.JSON(http.StatusInternalServerError, gin.H{
+ "status": http.StatusInternalServerError,
+ "message": err.Error(),
+ })
+ } else {
+ ctx.Status(http.StatusNoContent)
+ }
+}
+
+func EngagementMakeRecipe(ctx *gin.Context) {
+ deps := ctx.MustGet("deps").(*domain.InjectedDependencies)
+
+ if !domain.IsLoggedIn(ctx) {
+ ctx.Header("HX-Redirect", domain.WEB_LOGIN)
+ ctx.Status(http.StatusOK)
+ return
+ }
+
+ id := ctx.Param("id")
+ recipeId, _ := strconv.Atoi(id)
+ userId := ctx.MustGet("userId").(int)
+
+ if _, err := deps.EngagementService.UserMakeRecipe(userId, recipeId); err != nil {
+ ctx.JSON(http.StatusInternalServerError, gin.H{
+ "status": http.StatusInternalServerError,
+ "message": err.Error(),
+ })
+ } else {
+ ctx.Status(http.StatusNoContent)
+ }
+}
diff --git a/internal/app/handlers/page_handler.go b/internal/app/handlers/page_handler.go
index d375b78..cadcee1 100644
--- a/internal/app/handlers/page_handler.go
+++ b/internal/app/handlers/page_handler.go
@@ -60,7 +60,6 @@ func ProfilePage(ctx *gin.Context) {
user := deps.UserService.GetAuthenicatedUser(ctx)
recipes, err := deps.RecipeService.GetUserRecipes(user.Id)
if err != nil {
- fmt.Printf("Error getting recipes. %s\n", err.Error())
ctx.JSON(http.StatusInternalServerError, gin.H{
"status": http.StatusInternalServerError,
"message": fmt.Sprintf("Error getting recipes. %s\n", err.Error()),
@@ -68,8 +67,18 @@ func ProfilePage(ctx *gin.Context) {
return
}
+ // Get the engagement data, not sure what will happen when errors occur
+ engagements, err := deps.EngagementService.GetUserEngagement(user.Id, 6)
+ if err != nil {
+ ctx.JSON(http.StatusInternalServerError, gin.H{
+ "status": http.StatusInternalServerError,
+ "message": fmt.Sprintf("Error getting user engagements. %s\n", err.Error()),
+ })
+ return
+ }
+
title := "Potion - Profile"
- page := pages.ProfilePage(user, recipes)
+ page := pages.ProfilePage(user, recipes, engagements)
ctx.HTML(http.StatusOK, "", layouts.AppLayout(title, page))
}
@@ -95,15 +104,23 @@ func RecipePage(ctx *gin.Context) {
return
}
+ // Get signed in user, if they exist
+ var userId *int = nil
+ var loggedIn = domainServer.IsLoggedIn(ctx)
+ if loggedIn {
+ 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())
@@ -111,8 +128,24 @@ func RecipePage(ctx *gin.Context) {
return
}
+ // Add engagement
+ // BUG: Don't want to do this here
+ // if loggedIn {
+ // if _, err = deps.EngagementService.UserViewRecipe(*userId, recipe.Id); err != nil {
+ // fmt.Printf("ERROR: %s\n", err.Error())
+ // ctx.JSON(400, err.Error())
+ // return
+ // }
+ // } else {
+ // if _, err = deps.EngagementService.ViewRecipe(recipe.Id); err != nil {
+ // fmt.Printf("ERROR: %s\n", err.Error())
+ // ctx.JSON(400, err.Error())
+ // return
+ // }
+ // }
+
title := "Potion - View Recipe"
- page := pages.RecipePage(*recipe, *user)
+ page := pages.RecipePage(*recipe, *user, loggedIn)
ctx.HTML(http.StatusOK, "", layouts.AppLayout(title, page))
}
diff --git a/internal/app/server/server.go b/internal/app/server/server.go
index 406169b..091e464 100644
--- a/internal/app/server/server.go
+++ b/internal/app/server/server.go
@@ -115,14 +115,17 @@ func (s *Server) Setup() *Server {
// Initialize and inject dependencies
userRepo := repository.NewUserRepository(s.DB)
recipeRepo := repository.NewRecipeRepository(s.DB)
+ engagementRepo := repository.NewEngagementRepository(s.DB)
userService := service.NewUserService(userRepo)
authService := service.NewAuthService(userRepo, jwtSecret)
recipeService := service.NewRecipeService(recipeRepo)
+ engagementService := service.NewEngagementService(engagementRepo, recipeRepo)
deps := &domain.InjectedDependencies{
- UserService: userService,
- AuthService: authService,
- RecipeService: recipeService,
+ UserService: userService,
+ AuthService: authService,
+ RecipeService: recipeService,
+ EngagementService: engagementService,
}
// Apply middleware
@@ -183,6 +186,12 @@ func (s *Server) Setup() *Server {
router_api.POST("/recipe/search", handlers.SearchRecipes)
router_api.GET("/user/recipes", handlers.GetUserRecipes)
+ // Engagement endpoints
+ router_api.POST("/engagement/view/:id", handlers.EngagementViewRecipe)
+ router_api.POST("/engagement/share/:id", handlers.EngagementShareRecipe)
+ router_api.POST("/engagement/favorite/:id", handlers.EngagementFavoriteRecipe)
+ router_api.POST("/engagement/make/:id", handlers.EngagementMakeRecipe)
+
// Catch un-routed URLS
s.Router.NoRoute(func(ctx *gin.Context) {
path := ctx.Request.URL.Path
diff --git a/internal/app/service/engagement_service.go b/internal/app/service/engagement_service.go
new file mode 100644
index 0000000..54ec6a9
--- /dev/null
+++ b/internal/app/service/engagement_service.go
@@ -0,0 +1,129 @@
+package service
+
+import (
+ "fmt"
+
+ domain "github.com/haydenhargreaves/Potion/internal/domain/engagement"
+ domainRecipe "github.com/haydenhargreaves/Potion/internal/domain/recipe"
+ _ "github.com/lib/pq"
+)
+
+type EngagementService struct {
+ engagementRepository domain.EngagementRepository
+ recipeRepository domainRecipe.RecipeRepository
+}
+
+// Compile-time check to ensure the EngagementService implements domain.EngagementService
+var _ domain.EngagementService = (*EngagementService)(nil)
+
+// NewUserRepository creates a user repository object which is used by the user service to access
+// the database. Any user related database operations will take place in this repository.
+func NewEngagementService(engagementRepository domain.EngagementRepository, recipeRepository domainRecipe.RecipeRepository) domain.EngagementService {
+ return &EngagementService{
+ engagementRepository: engagementRepository,
+ recipeRepository: recipeRepository,
+ }
+}
+
+// ViewRecipe 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 view engagement to the
+// database.
+func (s *EngagementService) ViewRecipe(recipeId int) (domain.Engagement, error) {
+ recipe, err := s.recipeRepository.GetRecipe(recipeId, nil)
+ if err != nil {
+ return domain.Engagement{}, err
+ }
+
+ message := fmt.Sprintf("Viewed \"%s\"", recipe.Title)
+
+ return s.engagementRepository.AddEntityEngagement(recipeId, message, domain.EngagementViewed)
+}
+
+// ShareRecipe 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 view engagement to the
+// database.
+func (s *EngagementService) ShareRecipe(recipeId int) (domain.Engagement, error) {
+ recipe, err := s.recipeRepository.GetRecipe(recipeId, nil)
+ if err != nil {
+ return domain.Engagement{}, err
+ }
+
+ message := fmt.Sprintf("Shared \"%s\"", recipe.Title)
+
+ return s.engagementRepository.AddEntityEngagement(recipeId, message, domain.EngagementShared)
+}
+
+// UserViewRecipe 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 view engagement to the
+// database.
+func (s *EngagementService) UserViewRecipe(userId, recipeId int) (domain.Engagement, error) {
+ recipe, err := s.recipeRepository.GetRecipe(recipeId, &userId)
+ if err != nil {
+ return domain.Engagement{}, err
+ }
+
+ message := fmt.Sprintf("Viewed \"%s\"", recipe.Title)
+
+ return s.engagementRepository.AddUserEntityEngagement(userId, recipeId, message, domain.EngagementViewed)
+}
+
+// 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
+// database.
+func (s *EngagementService) UserFavoriteRecipe(userId, recipeId int) (domain.Engagement, error) {
+ recipe, err := s.recipeRepository.GetRecipe(recipeId, &userId)
+ if err != nil {
+ return domain.Engagement{}, err
+ }
+
+ // 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)
+
+}
+
+// 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 make engagement to the
+// database.
+func (s *EngagementService) UserMakeRecipe(userId, recipeId int) (domain.Engagement, error) {
+ recipe, err := s.recipeRepository.GetRecipe(recipeId, &userId)
+ if err != nil {
+ return domain.Engagement{}, err
+ }
+
+ message := fmt.Sprintf("Made \"%s\"", recipe.Title)
+
+ return s.engagementRepository.AddUserEntityEngagement(userId, recipeId, message, domain.EngagementMade)
+}
+
+// UserShareRecipe 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 make engagement to the
+// database.
+func (s *EngagementService) UserShareRecipe(userId, recipeId int) (domain.Engagement, error) {
+ recipe, err := s.recipeRepository.GetRecipe(recipeId, &userId)
+ if err != nil {
+ return domain.Engagement{}, err
+ }
+
+ message := fmt.Sprintf("Shared \"%s\"", recipe.Title)
+
+ return s.engagementRepository.AddUserEntityEngagement(userId, recipeId, message, domain.EngagementShared)
+}
+
+// GetUserEngagement returns a list of the users most recent engagement entries. The number of records
+// is determined by the limit passed into this function. The results are sorted, newest-to-oldest.
+func (s *EngagementService) GetUserEngagement(userId, limit int) ([]domain.Engagement, error) {
+ return s.engagementRepository.GetUserEngagement(userId, limit)
+}
diff --git a/internal/app/service/recipe_service.go b/internal/app/service/recipe_service.go
index 870f053..be4ba73 100644
--- a/internal/app/service/recipe_service.go
+++ b/internal/app/service/recipe_service.go
@@ -33,7 +33,7 @@ func NewRecipeService(recipeRepository domain.RecipeRepository) domain.RecipeSer
// occur.
//
// TODO: Implement validation in the API.
-// TODO: Implement image creation and tag creation.
+// TODO: Implement image creation.
func (s *RecipeService) CreateRecipe(ctx *gin.Context) (*domain.Recipe, error) {
// Ensure user is logged in
if !domainServer.IsLoggedIn(ctx) {
@@ -110,7 +110,7 @@ func (s *RecipeService) CreateRecipe(ctx *gin.Context) (*domain.Recipe, error) {
if image != nil {
}
- // TODO: Create the tags in the database
+ // Create the tags
if len(tags) > 0 {
if err := s.recipeRepository.CreateRecipeTags(recipe, tags); err != nil {
return &recipe, fmt.Errorf("Failed to attach/create tags. %s\n", err.Error())
@@ -123,8 +123,12 @@ 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)
+//
+// A userId should be provided to allow the favorite status to be updated. Without a userId (nil),
+// the favorite status will return false, not because its not a favorite, but because it cannot find
+// out!
+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/engagement/engagement.go b/internal/domain/engagement/engagement.go
new file mode 100644
index 0000000..92ca859
--- /dev/null
+++ b/internal/domain/engagement/engagement.go
@@ -0,0 +1,28 @@
+package domain
+
+import "time"
+
+// EngagementType is the database enum E_ENGAGEMENT which defines the type of a user engagement
+// of a recipe. Postgres enums are case sensitive so these must match the values in the database
+// exactly.
+type EngagementType string
+
+const (
+ EngagementMade EngagementType = "made"
+ EngagementLiked EngagementType = "liked"
+ EngagementViewed EngagementType = "viewed"
+ EngagementShared EngagementType = "shared"
+ EngagementReviewed EngagementType = "reviewed"
+ EngagementRated EngagementType = "rated"
+)
+
+// Engagement is the database model of a user engagement. There is no need to map to a different
+// model so this will remain in the domain.
+type Engagement struct {
+ Id int
+ Type EngagementType
+ Message string
+ Entity int
+ UserId int
+ Created time.Time
+}
diff --git a/internal/domain/engagement/repository.go b/internal/domain/engagement/repository.go
new file mode 100644
index 0000000..294b794
--- /dev/null
+++ b/internal/domain/engagement/repository.go
@@ -0,0 +1,10 @@
+package domain
+
+type EngagementRepository interface {
+ AddUserEngagement(userId int, message string, engagementType EngagementType) (Engagement, error)
+ AddUserEntityEngagement(userId, entityId int, message string, engagementType EngagementType) (Engagement, error)
+ AddEngagement(message string, engagementType EngagementType) (Engagement, error)
+ AddEntityEngagement(entityId int, message string, engagementType EngagementType) (Engagement, error)
+ GetUserEngagement(userId, limit int) ([]Engagement, error)
+ UserFavoriteRecipeToggle(userId, recipeId int) (bool, error)
+}
diff --git a/internal/domain/engagement/service.go b/internal/domain/engagement/service.go
new file mode 100644
index 0000000..4c3a0ff
--- /dev/null
+++ b/internal/domain/engagement/service.go
@@ -0,0 +1,11 @@
+package domain
+
+type EngagementService interface {
+ ViewRecipe(recipeId int) (Engagement, error)
+ ShareRecipe(recipeId int) (Engagement, error)
+ UserViewRecipe(userId, recipeId int) (Engagement, error)
+ UserFavoriteRecipe(userId, recipeId int) (Engagement, error)
+ UserMakeRecipe(userId, recipeId int) (Engagement, error)
+ UserShareRecipe(userId, recipeId int) (Engagement, error)
+ GetUserEngagement(userId, limit int) ([]Engagement, error)
+}
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/domain/server/routes.go b/internal/domain/server/routes.go
index 194940e..b6823da 100644
--- a/internal/domain/server/routes.go
+++ b/internal/domain/server/routes.go
@@ -25,6 +25,11 @@ const API_AUTH_LOGOUT = VERSION + API + "/auth/logout"
const API_CREATE_RECIPE = VERSION + API + "/recipe"
const API_SEARCH_RECIPES = VERSION + API + "/recipe/search"
+const API_ENGAGEMENT_VIEW = VERSION + API + "/engagement/view/%d"
+const API_ENGAGEMENT_SHARE = VERSION + API + "/engagement/share/%d"
+const API_ENGAGEMENT_FAVORITE = VERSION + API + "/engagement/favorite/%d"
+const API_ENGAGEMENT_MAKE = VERSION + API + "/engagement/make/%d"
+
// State prefixed routes
const STATE_TAGS_CREATE = VERSION + WEB + STATE + "/tags"
const STATE_TAGS_DELETE = VERSION + WEB + STATE + "/tags/delete"
diff --git a/internal/domain/server/server.go b/internal/domain/server/server.go
index 71f9fa5..a8861aa 100644
--- a/internal/domain/server/server.go
+++ b/internal/domain/server/server.go
@@ -4,6 +4,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
domainAuth "github.com/haydenhargreaves/Potion/internal/domain/auth"
+ domainEngagement "github.com/haydenhargreaves/Potion/internal/domain/engagement"
domainRecipe "github.com/haydenhargreaves/Potion/internal/domain/recipe"
domainUser "github.com/haydenhargreaves/Potion/internal/domain/user"
)
@@ -11,12 +12,13 @@ import (
// InjectedDependencies is a collection of dependencies that are injected into the application. They
// are stored in the context and can be accessed by handlers via the context.
type InjectedDependencies struct {
- UserService domainUser.UserService
- AuthService domainAuth.AuthService
- RecipeService domainRecipe.RecipeService
+ UserService domainUser.UserService
+ AuthService domainAuth.AuthService
+ RecipeService domainRecipe.RecipeService
+ EngagementService domainEngagement.EngagementService
}
-// JwtClaims is the data stored in the JSON web token. All that is needed is the users ID and their
+// JwtClaims is the data stored in the JSON web token. All that is needed is the users ID and their
// Google email provided.
type JwtClaims struct {
UserId int `json:"id"`
diff --git a/internal/infrastructure/database/migrations/006_create_engagment_enum.sql b/internal/infrastructure/database/migrations/006_create_engagment_enum.sql
new file mode 100644
index 0000000..8d64d23
--- /dev/null
+++ b/internal/infrastructure/database/migrations/006_create_engagment_enum.sql
@@ -0,0 +1,16 @@
+-- Author: Hayden Hargreaves (hhargreaves2006@gmail.com)
+-- Desc: Create the E_ENGAGEMENT enum.
+-- Date: 07/13/2025
+
+BEGIN;
+
+CREATE TYPE E_ENGAGEMENT AS ENUM(
+ 'made',
+ 'liked', -- this is the same as saved/favorited
+ 'viewed',
+ 'shared',
+ 'reviewed',
+ 'rated'
+);
+
+COMMIT;
diff --git a/internal/infrastructure/database/migrations/007_create_engagement_table.sql b/internal/infrastructure/database/migrations/007_create_engagement_table.sql
new file mode 100644
index 0000000..a8831ff
--- /dev/null
+++ b/internal/infrastructure/database/migrations/007_create_engagement_table.sql
@@ -0,0 +1,16 @@
+-- Author: Hayden Hargreaves (hhargreaves2006@gmail.com)
+-- Desc: Create the user engagement table.
+-- Date: 07/13/2025
+
+BEGIN;
+
+CREATE TABLE IF NOT EXISTS Engagements (
+ Id SERIAL PRIMARY KEY NOT NULL,
+ Type E_ENGAGEMENT NOT NULL,
+ Message TEXT,
+ Entity INT, -- Used to map to other DB objects, recipes, users, etc...
+ UserId INTEGER REFERENCES users(id), -- Can be null, when users aren't logged in
+ Created TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+COMMIT;
diff --git a/internal/infrastructure/database/migrations/008_create_favorites_table.sql b/internal/infrastructure/database/migrations/008_create_favorites_table.sql
new file mode 100644
index 0000000..8997128
--- /dev/null
+++ b/internal/infrastructure/database/migrations/008_create_favorites_table.sql
@@ -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;
diff --git a/internal/infrastructure/database/repository/engagement_repository.go b/internal/infrastructure/database/repository/engagement_repository.go
new file mode 100644
index 0000000..16cdc9b
--- /dev/null
+++ b/internal/infrastructure/database/repository/engagement_repository.go
@@ -0,0 +1,355 @@
+package repository
+
+import (
+ "database/sql"
+ "errors"
+ "fmt"
+ "time"
+
+ domain "github.com/haydenhargreaves/Potion/internal/domain/engagement"
+ _ "github.com/lib/pq"
+)
+
+type EngagementRepository struct {
+ db *sql.DB
+}
+
+// Compile-time check to ensure the EngagementRepository implements domain.EngagementRepository
+var _ domain.EngagementRepository = (*EngagementRepository)(nil)
+
+// NewUserRepository creates a user repository object which is used by the user service to access
+// the database. Any user related database operations will take place in this repository.
+func NewEngagementRepository(db *sql.DB) domain.EngagementRepository {
+ return &EngagementRepository{db: db}
+}
+
+// AddUserEngagement creates an engagement record in the database with the user ID provided. This
+// function does not accept an entity ID as it should be used when there is no need to reference
+// an entity. The message should be provided, but a blank string ("") is acceptable. The engagement
+// type parameter determines the labeling of the engagement in the database. Any errors will be
+// bubbled to the caller.
+func (r *EngagementRepository) AddUserEngagement(userId int, message string, engagementType domain.EngagementType) (domain.Engagement, error) {
+ tx, err := r.db.Begin()
+ if err != nil {
+ tx.Rollback()
+ return domain.Engagement{}, err
+ }
+
+ query := `
+ INSERT INTO Engagements (
+ type, message, entity, userid, created
+ ) VALUES (
+ $1, $2, NULL, $3, $4
+ ) RETURNING *;
+ `
+
+ var engagement domain.Engagement
+ var engUserId sql.NullInt32
+ if err := tx.QueryRow(query, engagementType, message, userId, time.Now()).Scan(
+ &engagement.Id,
+ &engagement.Type,
+ &engagement.Message,
+ &engagement.Entity,
+ &engUserId,
+ &engagement.Created,
+ ); err != nil {
+ tx.Rollback()
+ return domain.Engagement{}, fmt.Errorf("Failed to insert engagement into database. %s", err.Error())
+ }
+
+ if err := tx.Commit(); err != nil {
+ tx.Rollback()
+ return domain.Engagement{}, err
+ }
+
+ // Is user is valid
+ if engUserId.Valid {
+ engagement.UserId = int(engUserId.Int32)
+ }
+
+ return engagement, nil
+}
+
+// AddUserEntityEngagement creates an engagement record in the database with the user ID provided. This
+// function requires an entity ID as it should be used when there is a reference to external an
+// entity. The message should be provided, but a blank string ("") is acceptable. The engagement
+// type parameter determines the labeling of the engagement in the database. Any errors will be
+// bubbled to the caller.
+//
+// TODO: Disallow users to "make" the same recipe more than once a day
+func (r *EngagementRepository) AddUserEntityEngagement(userId, entityId int, message string, engagementType domain.EngagementType) (domain.Engagement, error) {
+ tx, err := r.db.Begin()
+ if err != nil {
+ tx.Rollback()
+ return domain.Engagement{}, err
+ }
+
+ query := `
+ INSERT INTO Engagements (
+ type, message, entity, userid, created
+ ) VALUES (
+ $1, $2, $3, $4, $5
+ ) RETURNING *;
+ `
+
+ var engagement domain.Engagement
+ var engUserId sql.NullInt32
+ if err := tx.QueryRow(query, engagementType, message, entityId, userId, time.Now()).Scan(
+ &engagement.Id,
+ &engagement.Type,
+ &engagement.Message,
+ &engagement.Entity,
+ &engUserId,
+ &engagement.Created,
+ ); err != nil {
+ tx.Rollback()
+ return domain.Engagement{}, fmt.Errorf("Failed to insert engagement into database. %s", err.Error())
+ }
+
+ if err := tx.Commit(); err != nil {
+ tx.Rollback()
+ return domain.Engagement{}, err
+ }
+
+ // Is user is valid
+ if engUserId.Valid {
+ engagement.UserId = int(engUserId.Int32)
+ }
+
+ return engagement, nil
+}
+
+// AddEngagement creates an engagement record in the database without any user. This function does
+// not accept an entity ID as it should be used when there is no need to reference an entity or user.
+// The message should be provided, but a blank string ("") is acceptable. The engagement type
+// parameter determines the labeling of the engagement in the database. Any errors will be bubbled
+// to the caller.
+//
+// List of allowed engagements: viewed, shared
+func (r *EngagementRepository) AddEngagement(message string, engagementType domain.EngagementType) (domain.Engagement, error) {
+ // Prevent invalid engagement types
+ switch engagementType {
+ case domain.EngagementViewed:
+ case domain.EngagementShared:
+ break
+ case domain.EngagementMade:
+ case domain.EngagementLiked:
+ case domain.EngagementReviewed:
+ case domain.EngagementRated:
+ return domain.Engagement{}, fmt.Errorf("Attempting to use disallowed anonymous engagement type.")
+ }
+
+ tx, err := r.db.Begin()
+ if err != nil {
+ tx.Rollback()
+ return domain.Engagement{}, err
+ }
+
+ query := `
+ INSERT INTO Engagements (
+ type, message, entity, userid, created
+ ) VALUES (
+ $1, $2, NULL, NULL, $3
+ ) RETURNING *;
+ `
+
+ var engagement domain.Engagement
+ var userId sql.NullInt32
+ if err := tx.QueryRow(query, engagementType, message, time.Now()).Scan(
+ &engagement.Id,
+ &engagement.Type,
+ &engagement.Message,
+ &engagement.Entity,
+ &userId,
+ &engagement.Created,
+ ); err != nil {
+ tx.Rollback()
+ return domain.Engagement{}, fmt.Errorf("Failed to insert engagement into database. %s", err.Error())
+ }
+
+ if err := tx.Commit(); err != nil {
+ tx.Rollback()
+ return domain.Engagement{}, err
+ }
+
+ // Is user is valid
+ if userId.Valid {
+ engagement.UserId = int(userId.Int32)
+ }
+
+ return engagement, nil
+}
+
+// AddEntityEngagement creates an engagement record in the database without any user. This function
+// requires an entity ID as it should be used when there is a reference to external an entity.
+// The message should be provided, but a blank string ("") is acceptable. The engagement type
+// parameter determines the labeling of the engagement in the database. Any errors will be
+// bubbled to the caller.
+//
+// List of allowed engagements: viewed, shared
+func (r *EngagementRepository) AddEntityEngagement(entityId int, message string, engagementType domain.EngagementType) (domain.Engagement, error) {
+ // Prevent invalid engagement types
+ switch engagementType {
+ case domain.EngagementViewed:
+ case domain.EngagementShared:
+ break
+ case domain.EngagementMade:
+ case domain.EngagementLiked:
+ case domain.EngagementReviewed:
+ case domain.EngagementRated:
+ return domain.Engagement{}, fmt.Errorf("Attempting to use disallowed anonymous engagement type.")
+ }
+
+ tx, err := r.db.Begin()
+ if err != nil {
+ tx.Rollback()
+ return domain.Engagement{}, err
+ }
+
+ query := `
+ INSERT INTO Engagements (
+ type, message, entity, userid, created
+ ) VALUES (
+ $1, $2, $3, NULL, $4
+ ) RETURNING *;
+ `
+
+ var engagement domain.Engagement
+ var userId sql.NullInt32
+ if err := tx.QueryRow(query, engagementType, message, entityId, time.Now()).Scan(
+ &engagement.Id,
+ &engagement.Type,
+ &engagement.Message,
+ &engagement.Entity,
+ &userId,
+ &engagement.Created,
+ ); err != nil {
+ tx.Rollback()
+ return domain.Engagement{}, fmt.Errorf("Failed to insert engagement into database. %s", err.Error())
+ }
+
+ if err := tx.Commit(); err != nil {
+ tx.Rollback()
+ return domain.Engagement{}, err
+ }
+
+ // Is user is valid
+ if userId.Valid {
+ engagement.UserId = int(userId.Int32)
+ }
+
+ return engagement, nil
+}
+
+// GetUserEngagement returns a list of the users most recent engagement entries. The number of records
+// is determined by the limit passed into this function. The results are sorted, newest-to-oldest.
+func (r *EngagementRepository) GetUserEngagement(userId, limit int) ([]domain.Engagement, error) {
+ tx, err := r.db.Begin()
+ if err != nil {
+ tx.Rollback()
+ return []domain.Engagement{}, err
+ }
+
+ query := `
+ SELECT * FROM Engagements
+ WHERE Userid = $1
+ ORDER BY created DESC LIMIT $2;
+ `
+
+ rows, err := tx.Query(query, userId, 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
+// 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
+}
diff --git a/internal/infrastructure/database/repository/recipe_repository.go b/internal/infrastructure/database/repository/recipe_repository.go
index e92739a..a0a578d 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()
@@ -240,8 +251,6 @@ func (r *RecipeRepository) SearchRecipes(filters domain.SearchFilters) ([]domain
}
}
- // TODO: Title search somehow...
-
// Merge condition strings
mealString := fmt.Sprintf("(%s)", strings.Join(mealConditions, " OR "))
timeString := fmt.Sprintf("(%s)", strings.Join(timeConditions, " OR "))
@@ -368,7 +377,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 +528,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 +589,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/favorites.templ b/internal/templates/pages/favorites.templ
index 70b1267..853262f 100644
--- a/internal/templates/pages/favorites.templ
+++ b/internal/templates/pages/favorites.templ
@@ -3,13 +3,13 @@ package templates
import "github.com/haydenhargreaves/Potion/internal/templates/components"
templ FavoritesPage() {
- @components.Navbar("favorites")
-
- Welcome to your ultimate recipe hub! Whether you're a seasoned chef or just starting your culinary adventure,
- we're here to inspire. Explore thousands of delicious recipes, from quick weeknight dinners to gourmet delights,
- all at your fingertips. Find exactly what you're craving with our powerful search and intuitive filters, or
- browse our trending dishes for fresh ideas.
-
-
+
+
+
+
+ Discover Your Next Favorite Meal
+
+
+
+ Welcome to your ultimate recipe hub! Whether you're a seasoned chef or just starting your culinary adventure,
+ we're here to inspire. Explore thousands of delicious recipes, from quick weeknight dinners to gourmet delights,
+ all at your fingertips. Find exactly what you're craving with our powerful search and intuitive filters, or
+ browse our trending dishes for fresh ideas.
+
+
+
}
templ highlightSection(liked bool) {
-
- @components.BannerText("Recipe of the Week!")
-
- Our 'Recipe of the Week' is the cream of the crop! We handpick it by looking at what recipes
- our community loves most. This isn't just about how many people view a recipe; it's also about
- how many times it's been made, liked, reviewed, and its average rating, all combined to find
- the true fan favorite of the week. It's our way of highlighting the best recipes that truly
- resonate with our users!
-
-
- @components.RecipeCardLarge(false)
-
-
+
+ @components.BannerText("Recipe of the Week!")
+
+ Our 'Recipe of the Week' is the cream of the crop! We handpick it by looking at what recipes
+ our community loves most. This isn't just about how many people view a recipe; it's also about
+ how many times it's been made, liked, reviewed, and its average rating, all combined to find
+ the true fan favorite of the week. It's our way of highlighting the best recipes that truly
+ resonate with our users!
+
Our 'Recipe of the Week' is the cream of the crop! We handpick it by looking at what recipes our community loves most. This isn't just about how many people view a recipe; it's also about how many times it's been made, liked, reviewed, and its average rating, all combined to find the true fan favorite of the week. It's our way of highlighting the best recipes that truly resonate with our users!
Our 'Recipe of the Week' is the cream of the crop! We handpick it by looking at what recipes our community loves most. This isn't just about how many people view a recipe; it's also about how many times it's been made, liked, reviewed, and its average rating, all combined to find the true fan favorite of the week. It's our way of highlighting the best recipes that truly resonate with our users!