(FEAT): Orm work, not complete, but editing needs to be merged.

This commit is contained in:
Hayden Hargreaves 2026-02-07 11:17:00 -07:00
parent e81f2ec513
commit 6e28ebfe80
4 changed files with 184 additions and 167 deletions

View File

@ -131,7 +131,7 @@ func (s *RecipeService) DeleteRecipe(userId, recipeId int) error {
return fmt.Errorf("User id does not match. Do you own the target recipe?") return fmt.Errorf("User id does not match. Do you own the target recipe?")
} }
return s.recipeRepository.DeleteRecipe(recipeId) return s.recipeRepository.DeleteRecipe(recipeId, userId)
} }
// GetRecipe will get a recipe via its ID. Any errors will be bubbled to the caller. Furthermore, // GetRecipe will get a recipe via its ID. Any errors will be bubbled to the caller. Furthermore,

View File

@ -3,7 +3,7 @@ package domain
type RecipeRepository interface { type RecipeRepository interface {
CreateRecipe(recipe *Recipe) error CreateRecipe(recipe *Recipe) error
EditRecipe(recipe *Recipe, userId int) error EditRecipe(recipe *Recipe, userId int) error
DeleteRecipe(recipeId int) error DeleteRecipe(recipeId, userId int) error
GetRecipe(id int, userId *int) (*Recipe, error) GetRecipe(id int, userId *int) (*Recipe, error)
GetRecipes(ids []int, userId *int) ([]Recipe, error) GetRecipes(ids []int, userId *int) ([]Recipe, error)
SearchRecipes(filters SearchFilters, userId *int, favorites bool) ([]int, error) SearchRecipes(filters SearchFilters, userId *int, favorites bool) ([]int, error)

View File

@ -3,7 +3,6 @@ package repository
import ( import (
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"strings" "strings"
"time" "time"
@ -34,26 +33,7 @@ func NewRecipeRepository(db *sqlx.DB) domain.RecipeRepository {
// be bubbled to the caller. The recipe parameter is passed by reference and will therefore be updated // 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. // directly and the new fields (ID, created) can be accessed upon success.
func (r *RecipeRepository) CreateRecipe(recipe *domain.Recipe) error { func (r *RecipeRepository) CreateRecipe(recipe *domain.Recipe) error {
tx, err := r.db.Begin() // Convert data into a readable format
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) durationJSON, err := json.Marshal(recipe.Duration)
if err != nil { if err != nil {
return err return err
@ -74,27 +54,46 @@ func (r *RecipeRepository) CreateRecipe(recipe *domain.Recipe) error {
instructions[i] = instruction.Content instructions[i] = instruction.Content
} }
var id int psql := sq.StatementBuilder.PlaceholderFormat(sq.Dollar)
if err = tx.QueryRow(
query, query := psql.
recipe.Title, Insert("recipes").
recipe.Description, Columns(
pq.Array(instructions), "title",
recipe.Serves, "description",
recipe.Difficulty, "instructions",
durationJSON, "serves",
string(recipe.Category), "difficulty",
ingredientsJSON, "duration",
recipe.UserId, "category",
nil, "ingredients",
recipe.Created, "userid",
).Scan(&id); err != nil { "modified",
tx.Rollback() "created",
return err ).
Values(
recipe.Title,
recipe.Description,
pq.Array(instructions),
recipe.Serves,
recipe.Difficulty,
durationJSON,
string(recipe.Category),
ingredientsJSON,
recipe.UserId,
nil,
recipe.Created,
).
Suffix("RETURNING id")
_sql, args, err := query.ToSql()
if err != nil {
return fmt.Errorf("Failed to construct query: %w", err)
} }
if err := tx.Commit(); err != nil { var id int
return err if err := r.db.Get(&id, _sql, args...); err != nil {
return fmt.Errorf("Failed to create recipe: %w", err)
} }
// Set the new ID // Set the new ID
@ -107,36 +106,9 @@ func (r *RecipeRepository) CreateRecipe(recipe *domain.Recipe) error {
// function will fail - it will not know what recipe to edit. // function will fail - it will not know what recipe to edit.
func (r *RecipeRepository) EditRecipe(recipe *domain.Recipe, userId int) error { func (r *RecipeRepository) EditRecipe(recipe *domain.Recipe, userId int) error {
if recipe.Id <= 0 { if recipe.Id <= 0 {
return fmt.Errorf("[ERROR] Recipe must contain an ID. Cannot edit unknown recipe.") return fmt.Errorf("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) durationJSON, err := json.Marshal(recipe.Duration)
if err != nil { if err != nil {
return err return err
@ -157,38 +129,38 @@ func (r *RecipeRepository) EditRecipe(recipe *domain.Recipe, userId int) error {
instructions[i] = instruction.Content instructions[i] = instruction.Content
} }
result, err := tx.Exec( psql := sq.StatementBuilder.PlaceholderFormat(sq.Dollar)
query,
recipe.Title, query := psql.
recipe.Description, Update("recipes").
pq.Array(instructions), Set("title", recipe.Title).
recipe.Serves, Set("description", recipe.Description).
recipe.Difficulty, Set("instructions", pq.Array(instructions)).
durationJSON, Set("serves", recipe.Serves).
string(recipe.Category), Set("difficulty", recipe.Difficulty).
ingredientsJSON, Set("duration", durationJSON).
time.Now().UTC(), Set("category", string(recipe.Category)).
recipe.Id, Set("ingredients", ingredientsJSON).
userId, Set("modified", time.Now().UTC()).
) Where(sq.Eq{
"id": recipe.Id,
"userid": userId,
})
_sql, args, err := query.ToSql()
if err != nil { if err != nil {
tx.Rollback() return fmt.Errorf("Failed to construct query: %w", err)
return err
} }
rows, err := result.RowsAffected() result, err := r.db.Exec(_sql, args...)
if err != nil { if err != nil {
tx.Rollback() return fmt.Errorf("Failed to update recipe: %w", err)
return err
} }
if rows != 1 { if rows, err := result.RowsAffected(); err != nil {
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 err
} else if rows != 1 {
return fmt.Errorf("Modified an unexpected number of rows. Expected 1, modified %d.", rows)
} }
return nil return nil
@ -197,17 +169,35 @@ func (r *RecipeRepository) EditRecipe(recipe *domain.Recipe, userId int) error {
// DeleteRecipe deletes a recipe in the database. This is done by setting the deleted field to true. // 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, // 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. // 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 { func (r *RecipeRepository) DeleteRecipe(recipeId, userId int) error {
query := "UPDATE recipes SET deleted = TRUE WHERE id = $1" psql := sq.StatementBuilder.PlaceholderFormat(sq.Dollar)
result, err := r.db.Exec(query, recipeId) query := psql.
Update("recipes").
Set("deleted", true).
Set("modified", time.Now().UTC()).
Where(sq.Eq{
"id": recipeId,
"userid": userId,
"deleted": false,
})
sql, args, err := query.ToSql()
if err != nil { if err != nil {
return err return fmt.Errorf("Failed to build delete query: %w", err)
} }
rows, _ := result.RowsAffected() result, err := r.db.Exec(sql, args...)
if err != nil {
return fmt.Errorf("Failed to delete recipe: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("Failed to get rows affects: %w", err)
}
if rows != 1 { if rows != 1 {
return fmt.Errorf("[ERROR] Incorrect number of rows modified. Expected 1, received %d.", rows) return fmt.Errorf("Incorrect number of rows modified. Expected 1, received %d.", rows)
} }
return nil return nil
@ -646,28 +636,26 @@ func (r *RecipeRepository) UpdateRecipeTags(recipe domain.Recipe, tags []string)
// //
// This function will only return recipes that are not deleted. Any recipes marked deleted will be ignored // 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. // and the standard "not-found" error will be returned.
func (r *RecipeRepository) GetUserRecipesIds(user_id int) ([]int, error) { func (r *RecipeRepository) GetUserRecipesIds(userId int) ([]int, error) {
query := ` psql := sq.StatementBuilder.PlaceholderFormat(sq.Dollar)
SELECT id
FROM recipes
WHERE userid = $1 AND deleted = false
ORDER BY created DESC;
`
rows, err := r.db.Query(query, user_id) query := psql.
Select("id").
From("recipes").
Where(sq.Eq{
"userid": userId,
"deleted": false,
}).
OrderBy("created DESC")
_sql, args, err := query.ToSql()
if err != nil { if err != nil {
return nil, fmt.Errorf("Failed to query DB for user recipes. %s\n", err.Error()) return []int{}, fmt.Errorf("Failed to construct SQL query: %w", err)
} }
defer rows.Close()
var ids []int var ids []int
for rows.Next() { if err := r.db.Select(&ids, _sql, args...); err != nil {
var r_id int return []int{}, fmt.Errorf("Failed to get user recipes: %w", err)
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 return ids, nil
@ -681,28 +669,29 @@ func (r *RecipeRepository) GetUserRecipesIds(user_id int) ([]int, error) {
// //
// This function will only return recipes that are not deleted. Any recipes marked deleted will be ignored // 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. // and the standard "not-found" error will be returned.
func (r *RecipeRepository) GetUserFavoriteRecipesIds(id int) ([]int, error) { func (r *RecipeRepository) GetUserFavoriteRecipesIds(userId int) ([]int, error) {
query := ` psql := sq.StatementBuilder.PlaceholderFormat(sq.Dollar)
SELECT r.id
FROM favorites f query := psql.
JOIN recipes r ON r.id = f.recipeid Select("r.id").
WHERE f.userid = $1 AND deleted = false From("favorites f").
ORDER BY f.created DESC; Join("recipes r on r.id = f.recipeid").
` Where(sq.Eq{
rows, err := r.db.Query(query, id) "f.userid": userId,
"deleted": false,
}).
OrderBy("f.created DESC")
_sql, args, err := query.ToSql()
if err != nil { if err != nil {
return nil, fmt.Errorf("Failed to query DB for user recipes. %s\n", err.Error()) return []int{}, fmt.Errorf("Failed to construct SQL query: %w", err)
} }
defer rows.Close()
fmt.Println(_sql)
var ids []int var ids []int
for rows.Next() { if err := r.db.Select(&ids, _sql, args...); err != nil {
var r_id int return []int{}, fmt.Errorf("Failed to get users' favorite recipes: %w", err)
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 return ids, nil
@ -753,15 +742,24 @@ func (r *RecipeRepository) GetRecipeFavorite(recipe *domain.Recipe, userId int)
return nil return nil
} }
query := ` psql := sq.StatementBuilder.PlaceholderFormat(sq.Dollar)
SELECT COUNT(*)
FROM favorites query := psql.
WHERE recipeid = $1 AND userid = $2; Select("COUNT(*)").
` From("favorites").
Where(sq.Eq{
"recipeid": recipe.Id,
"userid": userId,
})
_sql, args, err := query.ToSql()
if err != nil {
return fmt.Errorf("Failed to construct SQL query: %w", err)
}
var count int var count int
if err := r.db.QueryRow(query, recipe.Id, userId).Scan(&count); err != nil { if err := r.db.Get(&count, _sql, args...); err != nil {
return fmt.Errorf("Failed to get recipe favorite. %s", err.Error()) return fmt.Errorf("Failed to get recipe favorite status: %w", err)
} }
recipe.Favorite = count > 0 recipe.Favorite = count > 0
@ -774,41 +772,56 @@ func (r *RecipeRepository) GetRecipeFavorite(recipe *domain.Recipe, userId int)
// table and return it. If there is no entry, nil will be returned. Any errors will be bubbled to // 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. // 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) { func (r *RecipeRepository) GetRecipeOfTheWeekId(userId *int) (*int, error) {
query := ` psql := sq.StatementBuilder.PlaceholderFormat(sq.Dollar)
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 query := psql.
if err := r.db.QueryRow(query).Scan(&id); err != nil { Select("r.id").
if errors.Is(err, sql.ErrNoRows) { From("recipes r").
Join("recipeoftheweek rw ON rw.recipeid = r.id").
Where(sq.Eq{"r.deleted": false}).
OrderBy("rw.created DESC").
Limit(1)
_sql, args, err := query.ToSql()
if err != nil {
return nil, fmt.Errorf("Failed to build SQL query: %w", err)
}
var recipeId int
if err := r.db.Get(&recipeId, _sql, args...); err != nil {
if err == sql.ErrNoRows {
return nil, nil return nil, nil
} }
return nil, fmt.Errorf("Failed to locate recipe in database: %s", err.Error()) return nil, fmt.Errorf("Failed to locate recipe in database: %s", err.Error())
} }
return &id, nil return &recipeId, nil
} }
// IsRecipeOwner takes two required arguments: a user id and a recipe id. This function queries the DB // 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. // 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) { func (r *RecipeRepository) IsRecipeOwner(userId, recipeId int) (bool, error) {
query := ` psql := sq.StatementBuilder.PlaceholderFormat(sq.Dollar)
SELECT
userid query := psql.
FROM recipes Select("userid").
WHERE deleted = false From("recipes").
AND id = $1; Where(sq.Eq{
` "id": recipeId,
"deleted": false,
})
_sql, args, err := query.ToSql()
if err != nil {
return false, err
}
var recipeOwnerId int var recipeOwnerId int
if err := r.db.QueryRow(query, recipeId).Scan(&recipeOwnerId); err != nil { if err := r.db.Get(&recipeOwnerId, _sql, args...); err != nil {
return false, fmt.Errorf("Failed to get recipe owner id: %s", err.Error()) if err == sql.ErrNoRows {
return false, nil
}
return false, fmt.Errorf("Failed to get recipe owner id: %w", err)
} }
return recipeOwnerId == userId, nil return recipeOwnerId == userId, nil

View File

@ -127,8 +127,12 @@ export default function RecipePage() {
<MadeButton id={recipe.Id} /> <MadeButton id={recipe.Id} />
<ShareButton id={recipe.Id} /> <ShareButton id={recipe.Id} />
{isAuthor && <DeleteButton clickHandler={deleteHandler} />} {isAuthor && (
{isAuthor && <EditButton clickHandler={editHandler} />} <>
<DeleteButton clickHandler={deleteHandler} />
<EditButton clickHandler={editHandler} />
</>
)}
</section> </section>
<div className="px-4 py-8 md:px-8"> <div className="px-4 py-8 md:px-8">
<h3 className="text-xl text-gray-800 font-semibold mb-2">About this recipe</h3> <h3 className="text-xl text-gray-800 font-semibold mb-2">About this recipe</h3>