(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 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,

View File

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

View File

@ -3,7 +3,6 @@ package repository
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"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
// 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
// Convert data into a readable format
durationJSON, err := json.Marshal(recipe.Duration)
if err != nil {
return err
@ -74,9 +54,24 @@ func (r *RecipeRepository) CreateRecipe(recipe *domain.Recipe) error {
instructions[i] = instruction.Content
}
var id int
if err = tx.QueryRow(
query,
psql := sq.StatementBuilder.PlaceholderFormat(sq.Dollar)
query := psql.
Insert("recipes").
Columns(
"title",
"description",
"instructions",
"serves",
"difficulty",
"duration",
"category",
"ingredients",
"userid",
"modified",
"created",
).
Values(
recipe.Title,
recipe.Description,
pq.Array(instructions),
@ -88,13 +83,17 @@ func (r *RecipeRepository) CreateRecipe(recipe *domain.Recipe) error {
recipe.UserId,
nil,
recipe.Created,
).Scan(&id); err != nil {
tx.Rollback()
return err
).
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 {
return err
var id int
if err := r.db.Get(&id, _sql, args...); err != nil {
return fmt.Errorf("Failed to create recipe: %w", err)
}
// 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.
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.")
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)
if err != nil {
return err
@ -157,38 +129,38 @@ func (r *RecipeRepository) EditRecipe(recipe *domain.Recipe, userId int) error {
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,
)
psql := sq.StatementBuilder.PlaceholderFormat(sq.Dollar)
query := psql.
Update("recipes").
Set("title", recipe.Title).
Set("description", recipe.Description).
Set("instructions", pq.Array(instructions)).
Set("serves", recipe.Serves).
Set("difficulty", recipe.Difficulty).
Set("duration", durationJSON).
Set("category", string(recipe.Category)).
Set("ingredients", ingredientsJSON).
Set("modified", time.Now().UTC()).
Where(sq.Eq{
"id": recipe.Id,
"userid": userId,
})
_sql, args, err := query.ToSql()
if err != nil {
tx.Rollback()
return err
return fmt.Errorf("Failed to construct query: %w", err)
}
rows, err := result.RowsAffected()
result, err := r.db.Exec(_sql, args...)
if err != nil {
tx.Rollback()
return err
return fmt.Errorf("Failed to update recipe: %w", 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 {
if rows, err := result.RowsAffected(); err != nil {
return err
} else if rows != 1 {
return fmt.Errorf("Modified an unexpected number of rows. Expected 1, modified %d.", rows)
}
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.
// 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"
func (r *RecipeRepository) DeleteRecipe(recipeId, userId int) error {
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 {
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 {
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
@ -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
// 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;
`
func (r *RecipeRepository) GetUserRecipesIds(userId int) ([]int, error) {
psql := sq.StatementBuilder.PlaceholderFormat(sq.Dollar)
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 {
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
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)
if err := r.db.Select(&ids, _sql, args...); err != nil {
return []int{}, fmt.Errorf("Failed to get user recipes: %w", err)
}
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
// 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)
func (r *RecipeRepository) GetUserFavoriteRecipesIds(userId int) ([]int, error) {
psql := sq.StatementBuilder.PlaceholderFormat(sq.Dollar)
query := psql.
Select("r.id").
From("favorites f").
Join("recipes r on r.id = f.recipeid").
Where(sq.Eq{
"f.userid": userId,
"deleted": false,
}).
OrderBy("f.created DESC")
_sql, args, err := query.ToSql()
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
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)
if err := r.db.Select(&ids, _sql, args...); err != nil {
return []int{}, fmt.Errorf("Failed to get users' favorite recipes: %w", err)
}
return ids, nil
@ -753,15 +742,24 @@ func (r *RecipeRepository) GetRecipeFavorite(recipe *domain.Recipe, userId int)
return nil
}
query := `
SELECT COUNT(*)
FROM favorites
WHERE recipeid = $1 AND userid = $2;
`
psql := sq.StatementBuilder.PlaceholderFormat(sq.Dollar)
query := psql.
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
if err := r.db.QueryRow(query, recipe.Id, userId).Scan(&count); err != nil {
return fmt.Errorf("Failed to get recipe favorite. %s", err.Error())
if err := r.db.Get(&count, _sql, args...); err != nil {
return fmt.Errorf("Failed to get recipe favorite status: %w", err)
}
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
// 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;
`
psql := sq.StatementBuilder.PlaceholderFormat(sq.Dollar)
var id int
if err := r.db.QueryRow(query).Scan(&id); err != nil {
if errors.Is(err, sql.ErrNoRows) {
query := psql.
Select("r.id").
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, 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
// 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;
`
psql := sq.StatementBuilder.PlaceholderFormat(sq.Dollar)
query := psql.
Select("userid").
From("recipes").
Where(sq.Eq{
"id": recipeId,
"deleted": false,
})
_sql, args, err := query.ToSql()
if err != nil {
return false, err
}
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())
if err := r.db.Get(&recipeOwnerId, _sql, args...); err != nil {
if err == sql.ErrNoRows {
return false, nil
}
return false, fmt.Errorf("Failed to get recipe owner id: %w", err)
}
return recipeOwnerId == userId, nil

View File

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