diff --git a/doc/TechnicalSpecification.md b/doc/TechnicalSpecification.md index 367385a..886ed13 100644 --- a/doc/TechnicalSpecification.md +++ b/doc/TechnicalSpecification.md @@ -275,16 +275,16 @@ found in **OTHER** section. - [ ] RecipeId (FK: Recipe.Id, Required) Serial - [ ] Created (Required) date/time stamp -- [ ] Tags: Represents a single tag that can be had by many recipes. - - [ ] ID (PK) Serial - - [ ] Name (Unique, Required) string(32) - - [ ] Created (Required) date/time stamp +- [x] Tags: Represents a single tag that can be had by many recipes. + - [x] ID (PK) Serial + - [x] Name (Unique, Required) string(32) + - [x] Created (Required) date/time stamp -- [ ] RecipeTags: **Many-to-many** table to represent a list of tags on a recipe. - - [ ] ID (PK) Serial - - [ ] RecipeId (FK: Recipe.Id, Required) Serial - - [ ] TagId (FK: Tag.Id, Required) Serial - - [ ] Created (Required) date/time stamp +- [x] RecipeTags: **Many-to-many** table to represent a list of tags on a recipe. + - [x] ID (PK) Serial + - [x] RecipeId (FK: Recipe.Id, Required) Serial + - [x] TagId (FK: Tag.Id, Required) Serial + - [x] Created (Required) date/time stamp - [ ] Lists: Represents a single users shopping list. - [ ] ID (PK) Serial diff --git a/internal/app/service/recipe_service.go b/internal/app/service/recipe_service.go index f6804f3..82e9696 100644 --- a/internal/app/service/recipe_service.go +++ b/internal/app/service/recipe_service.go @@ -112,6 +112,9 @@ func (s *RecipeService) CreateRecipe(ctx *gin.Context) (*domain.Recipe, error) { // TODO: Create the tags in the database 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()) + } } return &recipe, nil diff --git a/internal/domain/recipe/recipe.go b/internal/domain/recipe/recipe.go index 1eb61fc..013a091 100644 --- a/internal/domain/recipe/recipe.go +++ b/internal/domain/recipe/recipe.go @@ -54,7 +54,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. +// 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. type Recipe struct { Id int Title string @@ -68,6 +69,7 @@ type Recipe struct { UserId int Modified *time.Time // Pointer to allow null Created time.Time + Tags []Tag } // SearchFilters is a model which represents the required filters to complete a recipe search. @@ -80,3 +82,20 @@ type SearchFilters struct { Difficulty int ServingSize int } + +// Tag is a model which represents a single tag in the Tags table. A tag is mapped to a recipe +// using the RecipeTag data model and can be accessed via their ID from the DB. +type Tag struct { + Id int + Name string + Created time.Time +} + +// RecipeTag is a model which represents a single mapping in the RecipeTags table. This model +// is a many-to-many mapping for tags to recipes. +type RecipeTag struct { + Id int + RecipeId int + TagId int + Created time.Time +} diff --git a/internal/domain/recipe/repository.go b/internal/domain/recipe/repository.go index f89c026..b47ccd9 100644 --- a/internal/domain/recipe/repository.go +++ b/internal/domain/recipe/repository.go @@ -4,4 +4,5 @@ type RecipeRepository interface { CreateRecipe(recipe *Recipe) error GetRecipe(id int) (*Recipe, error) SearchRecipes(filters SearchFilters) ([]Recipe, error) + CreateRecipeTags(recipe Recipe, tags []string) error } diff --git a/internal/infrastructure/database/migrations/005_create_tags_tables.sql b/internal/infrastructure/database/migrations/005_create_tags_tables.sql new file mode 100644 index 0000000..70223b5 --- /dev/null +++ b/internal/infrastructure/database/migrations/005_create_tags_tables.sql @@ -0,0 +1,26 @@ +-- Author: Hayden Hargreaves (hhargreaves2006@gmail.com) +-- Desc: Create tables for the recipe tags and mapping tags to recipes. +-- Date: 07/12/2025 + +BEGIN; + +-- Create the tags table itself +CREATE TABLE IF NOT EXISTS Tags ( + Id SERIAL PRIMARY KEY NOT NULL, + Name VARCHAR(32) UNIQUE NOT NULL, + Created TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Create the recipe tag table to map tags to a recipe. This DB will be dynamically +-- cleared when recipes or tags are deleted. This will prevent issues when deleting +-- tags or recipes. +CREATE TABLE IF NOT EXISTS RecipeTags ( + Id SERIAL PRIMARY KEY NOT NULL, + RecipeId INT NOT NULL, + TagId INT NOT NULL, + Created TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT fk_recipe_id FOREIGN KEY (RecipeId) REFERENCES Recipes (Id) ON DELETE CASCADE, + CONSTRAINT fk_tag_id FOREIGN KEY (TagId) REFERENCES Tags (Id) ON DELETE CASCADE +); + +COMMIT; diff --git a/internal/infrastructure/database/repository/recipe_repository.go b/internal/infrastructure/database/repository/recipe_repository.go index bdbf297..293f9c8 100644 --- a/internal/infrastructure/database/repository/recipe_repository.go +++ b/internal/infrastructure/database/repository/recipe_repository.go @@ -128,6 +128,29 @@ func (r *RecipeRepository) GetRecipe(id int) (*domain.Recipe, error) { return nil, fmt.Errorf("Failed to location recipe in database: %s", err.Error()) } + // Get tags from external tables + query = ` + SELECT t.* FROM tags t + JOIN recipetags rt ON rt.tagid = t.id + WHERE rt.recipeid = $1; + ` + rows, err := tx.Query(query, recipe.Id) + if err != nil { + return nil, fmt.Errorf("Failed to get tags for recipe. %s\n", err.Error()) + } + defer rows.Close() + + for rows.Next() { + var tag domain.Tag + + err := rows.Scan(&tag.Id, &tag.Name, &tag.Created) + if err != nil { + return nil, fmt.Errorf("Failed to scan tag onto domain model. %s\n", err.Error()) + } + + recipe.Tags = append(recipe.Tags, tag) + } + if err := tx.Commit(); err != nil { tx.Rollback() return nil, err @@ -374,3 +397,62 @@ func (r *RecipeRepository) SearchRecipes(filters domain.SearchFilters) ([]domain return recipes, nil } + +// CreateRecipeTags accepts a list of tags (names) and a recipe (already created by the DB) and +// creates the tags that do not exists, and adds those that do exist to the mapping table for the +// recipe. The result is records in the RecipeTags mapping table that represent all of the new +// and existing tags provided to this function. The recipe object must only contain an ID to call +// this function successfully, therefore, it must be an existing recipe. Any errors will be bubbled +// to the caller. +func (r *RecipeRepository) CreateRecipeTags(recipe domain.Recipe, tags []string) error { + tx, err := r.db.Begin() + if err != nil { + tx.Rollback() + return err + } + + // Normalize the tag names (lower case with trimmed space) + normalized := make(map[string]struct{}) // Use map to disallow duplicates + for _, tag := range tags { + normalized[strings.ToLower(strings.TrimSpace(tag))] = struct{}{} + } + + // Insert the tags into the DB and return their IDS into the tag ID list + var tagIds []int + for tag := range normalized { + var tagId int + + query := ` + INSERT INTO tags (name) VALUES ($1) + ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name + RETURNING id; + ` + + err := tx.QueryRow(query, tag).Scan(&tagId) + if err != nil { + return fmt.Errorf("Failed to retrieve or create tag. %s\n", err.Error()) + } + + tagIds = append(tagIds, tagId) + } + + // Using a prepared statement, execute the mapping insertions one-by-one + for _, id := range tagIds { + stmt, err := tx.Prepare("INSERT INTO RecipeTags (RecipeId, TagId) VALUES ($1, $2);") + if err != nil { + return fmt.Errorf("Failed to create statement for recipe tag mapping. %s\n", err.Error()) + } + defer stmt.Close() + + if _, err := stmt.Exec(recipe.Id, id); err != nil { + return fmt.Errorf("Failed to insert tag-recipe-mapping. %s\n", err.Error()) + } + } + + if err := tx.Commit(); err != nil { + tx.Rollback() + return err + } + + return nil +} diff --git a/internal/templates/pages/recipe.templ b/internal/templates/pages/recipe.templ index f4dd207..21bff85 100644 --- a/internal/templates/pages/recipe.templ +++ b/internal/templates/pages/recipe.templ @@ -80,7 +80,7 @@ templ RecipePage(recipe domain.Recipe, user domainUser.User) { @ingredientList(recipe.Ingredients) @instructionList(recipe.Instructions) - @tagList(recipe.Created, recipe.Modified) + @tagList(recipe.Tags, recipe.Created, recipe.Modified) } @@ -150,14 +150,17 @@ templ instructionList(instructions []string) { } -templ tagList(created time.Time, modified *time.Time) { +templ tagList(tags []domain.Tag, created time.Time, modified *time.Time) {
Created: { created.Format("January 2, 2006") }
if modified != nil { @@ -190,11 +193,13 @@ templ ingredientListItem(name, quantity string, odd bool) { } templ instructionListItem(num int, content string) { -Created: ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
Created: ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var16 string templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(created.Format("January 2, 2006")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/pages/recipe.templ`, Line: 162, Col: 91} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/pages/recipe.templ`, Line: 165, Col: 91} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if modified != nil { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "Last Modified: ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "
Last Modified: ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var17 string templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(modified.Format("January 2, 2006")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/pages/recipe.templ`, Line: 164, Col: 92} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/pages/recipe.templ`, Line: 167, Col: 92} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var23 string templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(content) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/pages/recipe.templ`, Line: 201, Col: 23} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/pages/recipe.templ`, Line: 206, Col: 23} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "