package repository import ( "database/sql" "encoding/json" "errors" "fmt" "strings" "time" sq "github.com/Masterminds/squirrel" domain "github.com/haydenhargreaves/Potion/internal/domain/recipe" "github.com/jmoiron/sqlx" "github.com/lib/pq" ) type RecipeRepository struct { db *sqlx.DB } // Compile-time check to ensure the RecipeRepository implements domain.RecipeRepository var _ domain.RecipeRepository = (*RecipeRepository)(nil) // NewRecipeRepository creates a user repository object which is used by the user service to access // the database. Any recipe related database operations will take place in this repository. func NewRecipeRepository(db *sqlx.DB) domain.RecipeRepository { return &RecipeRepository{db: db} } // NOTE: This function modified the provided recipe with the new values, such as id and time stamp // CreateRecipe creates a recipe in the database. The recipe provided should contain all data except // time stamps and the ID; the database will fill them when the operation succeeds. Any errors will // be bubbled to the caller. The recipe parameter is passed by reference and will therefore be updated // directly and the new fields (ID, created) can be accessed upon success. func (r *RecipeRepository) CreateRecipe(recipe *domain.Recipe) error { tx, err := r.db.Begin() if err != nil { return err } query := `INSERT INTO recipes ( title, description, instructions, serves, difficulty, duration, category, ingredients, userid, modified, created ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11 ) RETURNING id;` // NOTE: Data steps // cast duration to JSON // convert ingredients to store type // cast store type to JSON // extract string instructions from type // cast category to string // use nil for the modified time durationJSON, err := json.Marshal(recipe.Duration) if err != nil { return err } ingredientsStore := domain.RecipeIngredientStore{ Sections: recipe.Sections, Ingredients: recipe.Ingredients, } ingredientsJSON, err := json.Marshal(ingredientsStore) if err != nil { return err } instructions := make([]string, len(recipe.Instructions)) for i, instruction := range recipe.Instructions { instructions[i] = instruction.Content } var id int if err = tx.QueryRow( query, recipe.Title, recipe.Description, pq.Array(instructions), recipe.Serves, recipe.Difficulty, durationJSON, string(recipe.Category), ingredientsJSON, recipe.UserId, nil, recipe.Created, ).Scan(&id); err != nil { tx.Rollback() return err } if err := tx.Commit(); err != nil { return err } // Set the new ID recipe.Id = id return nil } // EditRecipe updates a recipe in the database. The recipe provided must contain an ID, otherwise this // function will fail - it will not know what recipe to edit. func (r *RecipeRepository) EditRecipe(recipe *domain.Recipe, userId int) error { if recipe.Id <= 0 { return fmt.Errorf("[ERROR] Recipe must contain an ID. Cannot edit unknown recipe.") } tx, err := r.db.Begin() if err != nil { return err } // This query will ensure the userId matches the owner. query := `UPDATE recipes SET title = $1, description = $2, instructions = $3, serves = $4, difficulty = $5, duration = $6, category = $7, ingredients = $8, modified = $9 WHERE id = $10 AND userid = $11;` // NOTE: Data steps // cast duration to JSON // convert ingredients to store type // cast store type to JSON // extract string instructions from type // cast category to string // use nil for the modified time durationJSON, err := json.Marshal(recipe.Duration) if err != nil { return err } ingredientsStore := domain.RecipeIngredientStore{ Sections: recipe.Sections, Ingredients: recipe.Ingredients, } ingredientsJSON, err := json.Marshal(ingredientsStore) if err != nil { return err } instructions := make([]string, len(recipe.Instructions)) for i, instruction := range recipe.Instructions { instructions[i] = instruction.Content } result, err := tx.Exec( query, recipe.Title, recipe.Description, pq.Array(instructions), recipe.Serves, recipe.Difficulty, durationJSON, string(recipe.Category), ingredientsJSON, time.Now().UTC(), recipe.Id, userId, ) if err != nil { tx.Rollback() return err } rows, err := result.RowsAffected() if err != nil { tx.Rollback() return err } if rows != 1 { tx.Rollback() return fmt.Errorf("[ERROR] Modified an unexpected number of rows. Expected 1, modified %d.", rows) } if err := tx.Commit(); err != nil { return err } return nil } // DeleteRecipe deletes a recipe in the database. This is done by setting the deleted field to true. // This will create a "soft delete" effect. This function does not validate that the user is the owner, // so the caller should validate the owner. If any errors occur, they will be returned to the caller. func (r *RecipeRepository) DeleteRecipe(recipeId int) error { query := "UPDATE recipes SET deleted = TRUE WHERE id = $1" result, err := r.db.Exec(query, recipeId) if err != nil { return err } rows, _ := result.RowsAffected() if rows != 1 { return fmt.Errorf("[ERROR] Incorrect number of rows modified. Expected 1, received %d.", rows) } return nil } // 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. // // This function will only return recipes that are not deleted. Any recipes marked deleted will be ignored // and the standard "not-found" error will be returned. func (r *RecipeRepository) GetRecipe(id int, userId *int) (*domain.Recipe, error) { query := `SELECT id, title, description, instructions, serves, difficulty, duration, category, ingredients, userid, modified, created, deleted FROM recipes WHERE id = $1 AND deleted = false; ` var durationBytes []byte var instructions pq.StringArray var ingredientBytes []byte var recipe domain.Recipe if err := r.db.QueryRow(query, id).Scan( &recipe.Id, &recipe.Title, &recipe.Description, &instructions, &recipe.Serves, &recipe.Difficulty, &durationBytes, &recipe.Category, &ingredientBytes, &recipe.UserId, &recipe.Modified, &recipe.Created, &recipe.Deleted, ); err != nil { return nil, fmt.Errorf("Failed to location recipe in 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 store domain.RecipeIngredientStore if err := json.Unmarshal(ingredientBytes, &store); err != nil { // Check for unmarshal to support backwards compatability return nil, fmt.Errorf("Failed to parse ingredients from database: %s", err.Error()) } recipe.Ingredients = store.Ingredients recipe.Sections = store.Sections } else { recipe.Ingredients = []domain.RecipeIngredient{} } // Add instructions for _, instruction := range instructions { recipe.Instructions = append(recipe.Instructions, domain.RecipeInstruction{Content: instruction}) } // 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 } 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. // // This function calls a function that only returns recipes that are not deleted. Any recipes marked // deleted will be ignored and the standard "not-found" error will be returned. func (r *RecipeRepository) GetRecipes(ids []int, userId *int) ([]domain.Recipe, error) { var recipes []domain.Recipe for _, id := range ids { recipe, err := r.GetRecipe(id, userId) if err != nil { return nil, err } // Skip any un-found recipes...? if recipe != nil { recipes = append(recipes, *recipe) } } 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 } // SearchRecipes will search the recipe table using the provided filters and return an unbound list // of recipes. The filters are fairly complex, they are stored as bit masks. A more details // description can be found in the recipe service implementation. Any errors will be bubbled to the // caller. // // The favorites parameter is used to only return filters favorited by the userId provided. // // TODO: Pagination is required, to provide infinite scroll. // // 12/28/25: This function has changed, now longer returns the recipes, but their IDs for fetching // elsewhere. // // 2/3/26: Refactored this large function to use Squirrel for simpler generation. // // This function will only return recipes that are not deleted. Any recipes marked deleted will be ignored // and the standard "not-found" error will be returned. func (r *RecipeRepository) SearchRecipes(filters domain.SearchFilters, userId *int, favorites bool) ([]int, error) { psql := sq.StatementBuilder.PlaceholderFormat(sq.Dollar) query := psql.Select("r.id").From("recipes r") // Only select fields where the recipe ID can be found in the favorites table (mapped to user ID) if favorites && userId != nil { query = query. Join("favorites f ON f.recipeId = r.id"). Where(sq.Eq{"f.userid": *userId}) } // Compute and add meal type filters (7 bit options) var mealCategories []string for i := range 7 { if isBitActive(filters.MealType, i) { mealCategories = append(mealCategories, string(domain.ParseMeal(i))) } } if len(mealCategories) > 0 { query = query.Where(sq.Eq{"category": mealCategories}) } // Compute and add time filters (5 bit options) var timeOr sq.Or for i := range 5 { if isBitActive(filters.Time, i) { switch i { case 0: timeOr = append(timeOr, sq.Lt{"(duration->>'total')::int": 15}) case 1: timeOr = append(timeOr, sq.Expr("(duration->>'total')::int BETWEEN 15 AND 30")) case 2: timeOr = append(timeOr, sq.Expr("(duration->>'total')::int BETWEEN 30 AND 60")) case 3: timeOr = append(timeOr, sq.Expr("(duration->>'total')::int BETWEEN 60 AND 120")) case 4: timeOr = append(timeOr, sq.Gt{"(duration->>'total')::int": 120}) } } } if len(timeOr) > 0 { query = query.Where(timeOr) } // Compute and add difficulty filters (5 bit options) var difficulties []int for i := range 5 { if isBitActive(filters.Difficulty, i) { difficulties = append(difficulties, i+1) } } if len(difficulties) > 0 { query = query.Where(sq.Eq{"difficulty": difficulties}) } // Compute and add serving size filters (5 bit options) var servingOr sq.Or for i := range 5 { if isBitActive(filters.ServingSize, i) { switch i { case 0: servingOr = append(servingOr, sq.Expr("serves BETWEEN 1 AND 2")) case 1: servingOr = append(servingOr, sq.Expr("serves BETWEEN 2 AND 4")) case 2: servingOr = append(servingOr, sq.Expr("serves BETWEEN 4 AND 6")) case 3: servingOr = append(servingOr, sq.Expr("serves BETWEEN 6 AND 8")) case 4: servingOr = append(servingOr, sq.Gt{"serves": 8}) } } } if len(servingOr) > 0 { query = query.Where(servingOr) } // Handle search with full-text search and ILIKE fallback if filters.Search != "" { spl := strings.Split(filters.Search, " ") var cleaned []string // Sanitize search terms replacer := strings.NewReplacer( "'", "", "-", "", "&", "", "|", "", "!", "", ":", "", "(", "", ")", "", ) for _, term := range spl { q := strings.TrimSpace(replacer.Replace(term)) if q != "" { cleaned = append(cleaned, q+":*") // Add prefix matching } } if len(cleaned) > 0 { vectorQuery := strings.Join(cleaned, " | ") // Build search condition as raw SQL expression // We'll use sq.Expr for the entire OR clause var searchConditions []string var searchArgs []interface{} // Full-text search searchConditions = append(searchConditions, "r.search_vector @@ to_tsquery('english', ?)") searchArgs = append(searchArgs, vectorQuery) // ILIKE fallback for substring matching for _, term := range spl { cleanTerm := strings.TrimSpace(replacer.Replace(term)) if cleanTerm != "" { searchConditions = append(searchConditions, "r.title ILIKE ?") searchArgs = append(searchArgs, "%"+cleanTerm+"%") searchConditions = append(searchConditions, "r.description ILIKE ?") searchArgs = append(searchArgs, "%"+cleanTerm+"%") } } // Combine all conditions with OR searchExpr := fmt.Sprintf("(%s)", strings.Join(searchConditions, " OR ")) query = query.Where(sq.Expr(searchExpr, searchArgs...)) // Add ordering for search results query = query. OrderBy(fmt.Sprintf("CASE WHEN r.search_vector @@ to_tsquery('english', '%s') THEN 1 ELSE 2 END", vectorQuery)). OrderBy(fmt.Sprintf("ts_rank(r.search_vector, to_tsquery('english', '%s')) DESC", vectorQuery)). OrderBy(fmt.Sprintf("ts_rank_cd(r.search_vector, to_tsquery('english', '%s')) DESC", vectorQuery)) } } // Exclude deleted recipes query = query.Where(sq.Eq{"deleted": false}) sql, args, err := query.ToSql() if err != nil { return nil, fmt.Errorf("[ERROR] Failed to build query: %w", err) } fmt.Println(sql) fmt.Println(args) // Execute query using SQLX var ids []int if err = r.db.Select(&ids, sql, args...); err != nil { return nil, fmt.Errorf("[ERROR] Failed to query recipes: %w", err) } return ids, nil // LEGACY CODE // Compute meals type filters (there are 7 bits) // var mealConditions []string // for i := range 7 { // if isBitActive(filters.MealType, i) { // mealConditions = append(mealConditions, fmt.Sprintf("category = '%s'", domain.ParseMeal(i))) // } // } // // // Compute time filters (there are 5 bits) // var timeConditions []string // for i := range 5 { // var cond string // if isBitActive(filters.Time, i) { // switch i { // case 0: // cond = "(duration->>'total')::int < 15" // case 1: // cond = "(duration->>'total')::int BETWEEN 15 AND 30" // case 2: // cond = "(duration->>'total')::int BETWEEN 30 AND 60" // case 3: // cond = "(duration->>'total')::int BETWEEN 60 AND 120" // case 4: // cond = "(duration->>'total')::int > 120" // } // timeConditions = append(timeConditions, cond) // } // } // // // Compute difficulty filters (there are 5 bits) // var difficultyConditions []string // for i := range 5 { // if isBitActive(filters.Difficulty, i) { // cond := fmt.Sprintf("difficulty = '%d'", i+1) // difficultyConditions = append(difficultyConditions, cond) // } // } // // // Compute serving size filters (there are 5 bits) // var servingConditions []string // for i := range 5 { // var cond string // if isBitActive(filters.ServingSize, i) { // switch i { // case 0: // cond = "serves BETWEEN 1 AND 2" // case 1: // cond = "serves BETWEEN 2 AND 4" // case 2: // cond = "serves BETWEEN 4 AND 6" // case 3: // cond = "serves BETWEEN 6 AND 8" // case 4: // cond = "serves > 8" // } // servingConditions = append(servingConditions, cond) // } // } // // // Merge condition strings // mealString := fmt.Sprintf("(%s)", strings.Join(mealConditions, " OR ")) // timeString := fmt.Sprintf("(%s)", strings.Join(timeConditions, " OR ")) // difficultyString := fmt.Sprintf("(%s)", strings.Join(difficultyConditions, " OR ")) // servingString := fmt.Sprintf("(%s)", strings.Join(servingConditions, " OR ")) // // // Combine condition strings // var conditions []string // if len(mealConditions) > 0 { // conditions = append(conditions, mealString) // } // if len(timeConditions) > 0 { // conditions = append(conditions, timeString) // } // if len(difficultyConditions) > 0 { // conditions = append(conditions, difficultyString) // } // if len(servingConditions) > 0 { // conditions = append(conditions, servingString) // } // // // Define columns to select // columns := []string{ // "r.id", // } // // // Create search vector query with SAFE parameterization // var orderBy string = "" // var searchQuery string = "" // // if filters.Search != "" { // spl := strings.Split(filters.Search, " ") // var cleaned []string // // // Use a string replacer for safety // replacer := strings.NewReplacer( // "'", "", // "-", "", // "&", "", // "|", "", // "!", "", // ":", "", // Remove colons to prevent tsquery syntax injection // "(", "", // ")", "", // ) // // for i := range len(spl) { // q := strings.TrimSpace(replacer.Replace(spl[i])) // if q != "" { // // Add :* suffix for prefix matching // cleaned = append(cleaned, q+":*") // } // } // // // Join with OR operator for full-text search // vector_query := strings.Join(cleaned, " | ") // searchQuery = vector_query // // // Full-text search with prefix matching // searchCondition := fmt.Sprintf("r.search_vector @@ to_tsquery('english', '%s')", vector_query) // // // Add fallback ILIKE for true substring matching // // This catches cases where "pan" is inside "pancake" but not at word boundaries // var ilikeConditions []string // for _, term := range spl { // cleanTerm := strings.TrimSpace(replacer.Replace(term)) // if cleanTerm != "" { // ilikeConditions = append(ilikeConditions, fmt.Sprintf("(r.title ILIKE '%%%s%%' OR r.description ILIKE '%%%s%%')", cleanTerm, cleanTerm)) // } // } // // if len(ilikeConditions) > 0 { // searchCondition = fmt.Sprintf("(%s OR %s)", searchCondition, strings.Join(ilikeConditions, " OR ")) // } // // conditions = append(conditions, searchCondition) // // // Ranking with preference for full-text matches // orderBy = fmt.Sprintf(` // ORDER BY // CASE // WHEN r.search_vector @@ to_tsquery('english', '%s') THEN 1 // ELSE 2 // END, // ts_rank(r.search_vector, to_tsquery('english', '%s')) DESC, // ts_rank_cd(r.search_vector, to_tsquery('english', '%s')) DESC // `, searchQuery, searchQuery, searchQuery) // } // // // Generate the query // var query string // if favorites && userId != nil { // query = fmt.Sprintf( // "SELECT %s FROM recipes r JOIN favorites f ON f.recipeId = r.id", // strings.Join(columns, ","), // ) // conditions = append(conditions, fmt.Sprintf("f.userid = %d", *userId)) // } else { // query = fmt.Sprintf("SELECT %s FROM recipes r", strings.Join(columns, ",")) // } // // // Convert and append conditions if provided // conditions = append(conditions, "deleted = false") // if len(conditions) > 0 { // conditionsString := fmt.Sprintf("WHERE %s", strings.Join(conditions, " AND ")) // query = fmt.Sprintf("%s %s", query, conditionsString) // } // // // Append sorting order if exists // if len(orderBy) > 0 { // query = fmt.Sprintf("%s %s", query, orderBy) // } // // // Finish it off with a semicolon! // query += ";" // // // Execute the query // rows, err := r.db.Query(query) // if err != nil { // return []int{}, fmt.Errorf("failed to query recipes: %w", err) // } // defer rows.Close() // // var ids []int // for rows.Next() { // var id int // if err := rows.Scan(&id); err != nil { // return []int{}, fmt.Errorf("failed to extract ID: %s\n", err.Error()) // } // ids = append(ids, id) // } // // return ids, 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 { 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 } // UpdateRecipeTags replaces all existing tags for a recipe with a new list of tags. // It removes all current tag associations, creates any new tags that don't exist, // and creates new associations for the provided tags. The recipe object must contain // a valid ID. Any errors will be bubbled to the caller. func (r *RecipeRepository) UpdateRecipeTags(recipe domain.Recipe, tags []string) error { tx, err := r.db.Begin() if err != nil { return err } defer tx.Rollback() // Rollback if we don't commit if recipe.Id <= 0 { return fmt.Errorf("[ERROR] Recipe must have a valid ID") } // Step 1: Delete all existing tag associations for this recipe deleteQuery := `DELETE FROM RecipeTags WHERE RecipeId = $1;` if _, err := tx.Exec(deleteQuery, recipe.Id); err != nil { return fmt.Errorf("[ERROR] Failed to delete existing recipe tags: %w", err) } // Step 2: Normalize the tag names (lower case with trimmed space) normalized := make(map[string]struct{}) // Use map to disallow duplicates for _, tag := range tags { trimmed := strings.ToLower(strings.TrimSpace(tag)) if trimmed != "" { normalized[trimmed] = struct{}{} } } // If no tags provided, we're done (all tags removed) if len(normalized) == 0 { if err := tx.Commit(); err != nil { return err } return nil } // Step 3: 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("[ERROR] Failed to retrieve or create tag: %w", err) } tagIds = append(tagIds, tagId) } // Step 4: Insert the new tag associations // Use a single prepared statement for all inserts stmt, err := tx.Prepare("INSERT INTO RecipeTags (RecipeId, TagId) VALUES ($1, $2);") if err != nil { return fmt.Errorf("[ERROR] Failed to create statement for recipe tag mapping: %w", err) } defer stmt.Close() for _, id := range tagIds { if _, err := stmt.Exec(recipe.Id, id); err != nil { return fmt.Errorf("[ERROR] Failed to insert tag-recipe mapping: %w", err) } } // Commit the transaction if err := tx.Commit(); err != nil { return err } return nil } // GetUserRecipes gets a list of a users owned recipes. This function does not ensure the user is // authenticated or exists. If nothing is found, a blank slice will be returned. The resulting list // is sorted by the created dates, newest first. Any errors will be bubbled to the caller. // // 12/28/25: This now returns just the IDs, the service can handle fetching them. // // This function will only return recipes that are not deleted. Any recipes marked deleted will be ignored // and the standard "not-found" error will be returned. func (r *RecipeRepository) GetUserRecipesIds(user_id int) ([]int, error) { query := ` SELECT id FROM recipes WHERE userid = $1 AND deleted = false ORDER BY created DESC; ` rows, err := r.db.Query(query, user_id) if err != nil { return nil, fmt.Errorf("Failed to query DB for user recipes. %s\n", err.Error()) } defer rows.Close() var ids []int for rows.Next() { var r_id int if err := rows.Scan(&r_id); err != nil { return []int{}, fmt.Errorf("Failed to scan ID from db. %s\n", err.Error()) } ids = append(ids, r_id) } return ids, nil } // GetUserRecipes gets a list of a users favorited recipes. This function does not ensure the user is // authenticated or exists. If nothing is found, a blank slice will be returned. The resulting list // is sorted by the created dates, newest first. Any errors will be bubbled to the caller. // // 12/28/25: This now just returns the IDs, so the service can handle the fetching. // // This function will only return recipes that are not deleted. Any recipes marked deleted will be ignored // and the standard "not-found" error will be returned. func (r *RecipeRepository) GetUserFavoriteRecipesIds(id int) ([]int, error) { query := ` SELECT r.id FROM favorites f JOIN recipes r ON r.id = f.recipeid WHERE f.userid = $1 AND deleted = false ORDER BY f.created DESC; ` rows, err := r.db.Query(query, id) if err != nil { return nil, fmt.Errorf("Failed to query DB for user recipes. %s\n", err.Error()) } defer rows.Close() var ids []int for rows.Next() { var r_id int if err := rows.Scan(&r_id); err != nil { return []int{}, fmt.Errorf("Failed to scan ID from db. %s\n", err.Error()) } ids = append(ids, r_id) } return ids, nil } // GetRecipeTags 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 Tags array with the recipe's tags from the database. // The recipe is modified in place and is not returned. If the recipe is nil, the function will // return nothing (skipping). Any errors will be bubbled to the caller. func (r *RecipeRepository) GetRecipeTags(recipe *domain.Recipe) error { if recipe == nil { return nil } recipe.Tags = []domain.Tag{} query := ` SELECT t.* FROM tags t JOIN recipetags rt ON rt.tagid = t.id WHERE rt.recipeid = $1; ` rows, err := r.db.Query(query, recipe.Id) if err != nil { return 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 fmt.Errorf("Failed to scan tag onto domain model. %s\n", err.Error()) } recipe.Tags = append(recipe.Tags, tag) } 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. If the recipe is nil, the function // will return nothing (skipping). Any errors will be bubbled to the caller. func (r *RecipeRepository) GetRecipeFavorite(recipe *domain.Recipe, userId int) error { if recipe == nil { return nil } query := ` SELECT COUNT(*) FROM favorites WHERE recipeid = $1 AND userid = $2; ` var count int if err := r.db.QueryRow(query, recipe.Id, userId).Scan(&count); err != nil { return fmt.Errorf("Failed to get recipe favorite. %s", err.Error()) } recipe.Favorite = count > 0 return nil } // GetRecipeOfTheWeekId searches for the most recent recipe of the week. If there is not a value, // the recipe will be nil. This function simply collects the most recent entry in the recipeoftheweek // table and return it. If there is no entry, nil will be returned. Any errors will be bubbled to // the caller. All that is returned is the recipe ID, that way the caller can handle the fetching. func (r *RecipeRepository) GetRecipeOfTheWeekId(userId *int) (*int, error) { query := ` SELECT r.id FROM recipes r JOIN recipeoftheweek rw ON rw.recipeid = r.id WHERE r.deleted = false ORDER BY rw.created DESC LIMIT 1; ` var id int if err := r.db.QueryRow(query).Scan(&id); err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, nil } return nil, fmt.Errorf("Failed to locate recipe in database: %s", err.Error()) } return &id, nil } // IsRecipeOwner takes two required arguments: a user id and a recipe id. This function queries the DB // to check if the user is the owner of the provided recipe. Any error will be bubbled to the caller. func (r *RecipeRepository) IsRecipeOwner(userId, recipeId int) (bool, error) { query := ` SELECT userid FROM recipes WHERE deleted = false AND id = $1; ` var recipeOwnerId int if err := r.db.QueryRow(query, recipeId).Scan(&recipeOwnerId); err != nil { return false, fmt.Errorf("Failed to get recipe owner id: %s", err.Error()) } return recipeOwnerId == userId, nil }