Hayden Hargreaves 3b6ebd0dae (DB/UI): Created user list API and wired to UI.
The profile list will now properly display the users recipes! The
favorites list does not exist yet, since there is no backend support for
favoriting/saving recipes. So the list displays the same content as the
user recipe list. Same goes for the activity list, not yet implemented.
2025-07-13 14:01:05 -07:00

574 lines
15 KiB
Go

package repository
import (
"database/sql"
"encoding/json"
"fmt"
"strings"
domain "github.com/haydenhargreaves/Potion/internal/domain/recipe"
"github.com/lib/pq"
)
type RecipeRepository struct {
db *sql.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 *sql.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 {
tx.Rollback()
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
// cast ingredients to JSON
// cast category to string
// use nil for the modified time
durationJSON, err := json.Marshal(recipe.Duration)
if err != nil {
return err
}
ingredientsJSON, err := json.Marshal(recipe.Ingredients)
if err != nil {
return err
}
var id int
if err = tx.QueryRow(
query,
recipe.Title,
recipe.Description,
pq.Array(recipe.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 {
tx.Rollback()
return err
}
// Set the new ID
recipe.Id = id
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.
func (r *RecipeRepository) GetRecipe(id int) (*domain.Recipe, error) {
tx, err := r.db.Begin()
if err != nil {
tx.Rollback()
return nil, err
}
query := `
SELECT
id, title, description, instructions, serves, difficulty, duration, category, ingredients,
userid, modified, created
FROM recipes
WHERE id = $1
`
var durationBytes []byte
var ingredientBytes []byte
var recipe domain.Recipe
if err := tx.QueryRow(query, id).Scan(
&recipe.Id,
&recipe.Title,
&recipe.Description,
pq.Array(&recipe.Instructions),
&recipe.Serves,
&recipe.Difficulty,
&durationBytes,
&recipe.Category,
&ingredientBytes,
&recipe.UserId,
&recipe.Modified,
&recipe.Created,
); err != nil {
return nil, fmt.Errorf("Failed to 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 ingredients []domain.RecipeIngredient
if err := json.Unmarshal(ingredientBytes, &ingredients); err != nil {
return nil, fmt.Errorf("Failed to parse ingredients from database: %s", err.Error())
}
recipe.Ingredients = ingredients
} else {
recipe.Ingredients = []domain.RecipeIngredient{}
}
// Add tags
r.GetRecipeTags(&recipe)
if err := tx.Commit(); err != nil {
tx.Rollback()
return nil, err
}
return &recipe, 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.
//
// TODO: Pagination is required, to provide infinite scroll.
func (r *RecipeRepository) SearchRecipes(filters domain.SearchFilters) ([]domain.Recipe, error) {
tx, err := r.db.Begin()
if err != nil {
tx.Rollback()
return nil, err
}
// 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)
}
}
// TODO: Title search somehow...
// 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. More fields can be added if the full text search is required
columns := []string{
"id",
"title",
"description",
"instructions",
"serves",
"difficulty",
"duration",
"category",
"ingredients",
"userid",
"modified",
"created",
}
// Create search vector query
var orderBy string = ""
if filters.Search != "" {
vector_query := strings.ReplaceAll(filters.Search, " ", " | ")
conditions = append(
conditions,
fmt.Sprintf("search_vector @@ to_tsquery('english', '%s')", vector_query),
)
template := `
ORDER BY
ts_rank(search_vector, to_tsquery('english', '%s')) DESC,
ts_rank_cd(search_vector, to_tsquery('english', '%s')) DESC
`
orderBy = fmt.Sprintf(template, vector_query, vector_query)
}
// Generate the query
query := fmt.Sprintf("SELECT %s FROM recipes", strings.Join(columns, ","))
// Convert and append conditions if provided
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 colon!
query += ";"
// Execute the query
rows, err := tx.Query(query)
if err != nil {
return nil, fmt.Errorf("failed to query recipes: %w", err)
}
defer rows.Close()
var recipes []domain.Recipe
for rows.Next() {
// Parsed values location
var recipe domain.Recipe
var durationBytes []byte
var ingredientBytes []byte
if err := rows.Scan(
&recipe.Id,
&recipe.Title,
&recipe.Description,
pq.Array(&recipe.Instructions),
&recipe.Serves,
&recipe.Difficulty,
&durationBytes,
&recipe.Category,
&ingredientBytes,
&recipe.UserId,
&recipe.Modified,
&recipe.Created,
); err != nil {
return nil, fmt.Errorf("failed to scan recipe row: %w", err)
}
// Parse duration from bytes
if len(durationBytes) > 0 {
var duration domain.RecipeDuration
if err := json.Unmarshal(durationBytes, &duration); err != nil {
return nil, fmt.Errorf("failed to parse duration for recipe ID %d: %w", recipe.Id, err)
}
recipe.Duration = duration
} else {
recipe.Duration = domain.RecipeDuration{}
}
// Parse ingredients from bytes
if len(ingredientBytes) > 0 {
var ingredients []domain.RecipeIngredient
if err := json.Unmarshal(ingredientBytes, &ingredients); err != nil {
return nil, fmt.Errorf("failed to parse ingredients for recipe ID %d: %w", recipe.Id, err)
}
recipe.Ingredients = ingredients
} else {
recipe.Ingredients = []domain.RecipeIngredient{}
}
// Add tags
r.GetRecipeTags(&recipe)
recipes = append(recipes, recipe)
}
if err := tx.Commit(); err != nil {
tx.Rollback()
return nil, err
}
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
}
// 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.
func (r *RecipeRepository) GetUserRecipes(id int) ([]domain.Recipe, error) {
tx, err := r.db.Begin()
if err != nil {
tx.Rollback()
return nil, err
}
query := `
SELECT id, title, description, instructions, serves, difficulty, duration, category, ingredients,
userid, modified, created
FROM recipes
WHERE userid = $1
ORDER BY created DESC;
`
rows, err := tx.Query(query, id)
if err != nil {
return nil, fmt.Errorf("Failed to query DB for user recipes. %s\n", err.Error())
}
defer rows.Close()
// Prepare statement for tag query
// tagQuery := `
// `
var recipes []domain.Recipe
for rows.Next() {
var recipe domain.Recipe
var durationBytes []byte
var ingredientBytes []byte
// Scan results from recipe query onto recipe object
if err := rows.Scan(
&recipe.Id,
&recipe.Title,
&recipe.Description,
pq.Array(&recipe.Instructions),
&recipe.Serves,
&recipe.Difficulty,
&durationBytes,
&recipe.Category,
&ingredientBytes,
&recipe.UserId,
&recipe.Modified,
&recipe.Created,
); err != nil {
return nil, fmt.Errorf("Failed to scan row onto recipe object. %s\n", err.Error())
}
// Parse duration
if len(durationBytes) > 0 {
var duration domain.RecipeDuration
if err := json.Unmarshal(durationBytes, &duration); err != nil {
return nil, fmt.Errorf("Failed to parse duration from database: %s", err.Error())
}
recipe.Duration = duration
} else {
recipe.Duration = domain.RecipeDuration{}
}
// Parse ingredient
if len(ingredientBytes) > 0 {
var ingredients []domain.RecipeIngredient
if err := json.Unmarshal(ingredientBytes, &ingredients); err != nil {
return nil, fmt.Errorf("Failed to parse ingredients from database: %s", err.Error())
}
recipe.Ingredients = ingredients
} else {
recipe.Ingredients = []domain.RecipeIngredient{}
}
// Add tags
r.GetRecipeTags(&recipe)
recipes = append(recipes, recipe)
}
if err := tx.Commit(); err != nil {
tx.Rollback()
return nil, err
}
return recipes, 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. Any errors will be bubbled to the caller.
func (r *RecipeRepository) GetRecipeTags(recipe *domain.Recipe) error {
tx, err := r.db.Begin()
if err != nil {
tx.Rollback()
return err
}
recipe.Tags = []domain.Tag{}
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 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)
}
if err := tx.Commit(); err != nil {
tx.Rollback()
return err
}
return nil
}