2026-02-03 22:56:26 -07:00

1012 lines
29 KiB
Go

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
}