diff --git a/Dockerfile b/Dockerfile
index cbf0426..9b8013d 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -2,6 +2,9 @@ FROM golang:1.25-alpine
WORKDIR /app
+# Solution t IP block?
+ENV GOPROXY=https://goproxy.io,https://athens.azurefd.net,direct
+
COPY go.mod go.sum ./
RUN go mod download
diff --git a/go.mod b/go.mod
index 2b8eca0..43828b6 100644
--- a/go.mod
+++ b/go.mod
@@ -15,6 +15,7 @@ require (
require (
cloud.google.com/go/compute/metadata v0.3.0 // indirect
+ github.com/Masterminds/squirrel v1.5.4 // indirect
github.com/bytedance/sonic v1.13.2 // indirect
github.com/bytedance/sonic/loader v0.2.4 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect
@@ -25,10 +26,13 @@ require (
github.com/go-playground/validator/v10 v10.26.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/google/uuid v1.6.0 // indirect
+ github.com/jmoiron/sqlx v1.4.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/kelseyhightower/envconfig v1.4.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/kr/text v0.2.0 // indirect
+ github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
+ github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
diff --git a/go.sum b/go.sum
index a3a4fbc..e8280b6 100644
--- a/go.sum
+++ b/go.sum
@@ -1,5 +1,8 @@
cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
+filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
+github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM=
+github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10=
github.com/a-h/templ v0.3.898 h1:g9oxL/dmM6tvwRe2egJS8hBDQTncokbMoOFk1oJMX7s=
github.com/a-h/templ v0.3.898/go.mod h1:oLBbZVQ6//Q6zpvSMPTuBK0F3qOtBdFBcGRspcT+VNQ=
github.com/a-h/templ v0.3.920 h1:IQjjTu4KGrYreHo/ewzSeS8uefecisPayIIc9VflLSE=
@@ -48,6 +51,7 @@ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBEx
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
+github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
@@ -65,6 +69,8 @@ github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kX
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
+github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
+github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
@@ -78,12 +84,17 @@ github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw=
+github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o=
+github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk=
+github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -99,6 +110,7 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
+github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
diff --git a/internal/app/server/server.go b/internal/app/server/server.go
index 889d925..0670df7 100644
--- a/internal/app/server/server.go
+++ b/internal/app/server/server.go
@@ -1,7 +1,6 @@
package server
import (
- "database/sql"
"fmt"
"net/http"
"os"
@@ -18,6 +17,7 @@ import (
"github.com/haydenhargreaves/Potion/internal/infrastructure/database/repository"
"github.com/haydenhargreaves/Potion/internal/infrastructure/logging"
"github.com/haydenhargreaves/Potion/internal/infrastructure/logging/loggers"
+ "github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
)
@@ -26,7 +26,7 @@ type Server struct {
port int
Router *gin.Engine
config cors.Config
- DB *sql.DB
+ DB *sqlx.DB
deps domain.InjectedDependencies
logs []logging.Logger
cleanupFuncs []func() error
@@ -132,7 +132,7 @@ func (s *Server) Setup() *Server {
auth.NewGoogleConfig(redirectUrl, clientId, clientSecret, scope)
// SETUP DATABASE
- db, err := sql.Open("postgres", cfg.DatabaseUrl)
+ db, err := sqlx.Open("postgres", cfg.DatabaseUrl)
if err != nil {
panic("Could not connect to database: " + err.Error())
}
diff --git a/internal/app/service/recipe_service.go b/internal/app/service/recipe_service.go
index e455bf4..d02c1b2 100644
--- a/internal/app/service/recipe_service.go
+++ b/internal/app/service/recipe_service.go
@@ -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,
diff --git a/internal/domain/recipe/repository.go b/internal/domain/recipe/repository.go
index 9d4d305..55a2dc9 100644
--- a/internal/domain/recipe/repository.go
+++ b/internal/domain/recipe/repository.go
@@ -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)
diff --git a/internal/infrastructure/database/repository/engagement_repository.go b/internal/infrastructure/database/repository/engagement_repository.go
index 879105f..7b9beec 100644
--- a/internal/infrastructure/database/repository/engagement_repository.go
+++ b/internal/infrastructure/database/repository/engagement_repository.go
@@ -7,11 +7,12 @@ import (
"time"
domain "github.com/haydenhargreaves/Potion/internal/domain/engagement"
+ "github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
)
type EngagementRepository struct {
- db *sql.DB
+ db *sqlx.DB
}
// Compile-time check to ensure the EngagementRepository implements domain.EngagementRepository
@@ -19,7 +20,7 @@ var _ domain.EngagementRepository = (*EngagementRepository)(nil)
// NewUserRepository creates a user repository object which is used by the user service to access
// the database. Any user related database operations will take place in this repository.
-func NewEngagementRepository(db *sql.DB) domain.EngagementRepository {
+func NewEngagementRepository(db *sqlx.DB) domain.EngagementRepository {
return &EngagementRepository{db: db}
}
diff --git a/internal/infrastructure/database/repository/recipe_repository.go b/internal/infrastructure/database/repository/recipe_repository.go
index 856acaf..c31962a 100644
--- a/internal/infrastructure/database/repository/recipe_repository.go
+++ b/internal/infrastructure/database/repository/recipe_repository.go
@@ -3,17 +3,18 @@ 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 *sql.DB
+ db *sqlx.DB
}
// Compile-time check to ensure the RecipeRepository implements domain.RecipeRepository
@@ -21,7 +22,7 @@ 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 {
+func NewRecipeRepository(db *sqlx.DB) domain.RecipeRepository {
return &RecipeRepository{db: db}
}
@@ -32,26 +33,7 @@ func NewRecipeRepository(db *sql.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
@@ -72,27 +54,46 @@ func (r *RecipeRepository) CreateRecipe(recipe *domain.Recipe) error {
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
+ 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),
+ 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 {
- 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
@@ -105,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
@@ -155,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
@@ -195,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
@@ -245,7 +237,10 @@ func (r *RecipeRepository) GetRecipe(id int, userId *int) (*domain.Recipe, error
&recipe.Created,
&recipe.Deleted,
); err != nil {
- return nil, fmt.Errorf("Failed to location recipe in database: %s", err.Error())
+ if err == sql.ErrNoRows {
+ return nil, err
+ }
+ return nil, fmt.Errorf("Failed to location recipe (id: %d) in database: %s", id, err.Error())
}
// Parse duration
@@ -308,7 +303,7 @@ func (r *RecipeRepository) GetRecipes(ids []int, userId *int) ([]domain.Recipe,
for _, id := range ids {
recipe, err := r.GetRecipe(id, userId)
- if err != nil {
+ if err != nil && err != sql.ErrNoRows {
return nil, err
}
@@ -338,198 +333,165 @@ func isBitActive(bits, pos int) bool {
// 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. Reduced line count by 50,
+// but this is still insane. We need to clean this up.
+//
// 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) {
- // Compute meals type filters (there are 7 bits)
- var mealConditions []string
+ // Begin creating the query
+ 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) {
- mealConditions = append(mealConditions, fmt.Sprintf("category = '%s'", domain.ParseMeal(i)))
+ mealCategories = append(mealCategories, string(domain.ParseMeal(i)))
}
}
- // Compute time filters (there are 5 bits)
- var timeConditions []string
+ 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 {
- var cond string
if isBitActive(filters.Time, i) {
switch i {
case 0:
- cond = "(duration->>'total')::int < 15"
+ timeOr = append(timeOr, sq.Lt{"(duration->>'total')::int": 15})
case 1:
- cond = "(duration->>'total')::int BETWEEN 15 AND 30"
+ timeOr = append(timeOr, sq.Expr("(duration->>'total')::int BETWEEN 15 AND 30"))
case 2:
- cond = "(duration->>'total')::int BETWEEN 30 AND 60"
+ timeOr = append(timeOr, sq.Expr("(duration->>'total')::int BETWEEN 30 AND 60"))
case 3:
- cond = "(duration->>'total')::int BETWEEN 60 AND 120"
+ timeOr = append(timeOr, sq.Expr("(duration->>'total')::int BETWEEN 60 AND 120"))
case 4:
- cond = "(duration->>'total')::int > 120"
+ timeOr = append(timeOr, sq.Gt{"(duration->>'total')::int": 120})
}
- timeConditions = append(timeConditions, cond)
}
}
- // Compute difficulty filters (there are 5 bits)
- var difficultyConditions []string
+ 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) {
- cond := fmt.Sprintf("difficulty = '%d'", i+1)
- difficultyConditions = append(difficultyConditions, cond)
+ difficulties = append(difficulties, i+1)
}
}
- // Compute serving size filters (there are 5 bits)
- var servingConditions []string
+ 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 {
- var cond string
if isBitActive(filters.ServingSize, i) {
switch i {
case 0:
- cond = "serves BETWEEN 1 AND 2"
+ servingOr = append(servingOr, sq.Expr("serves BETWEEN 1 AND 2"))
case 1:
- cond = "serves BETWEEN 2 AND 4"
+ servingOr = append(servingOr, sq.Expr("serves BETWEEN 2 AND 4"))
case 2:
- cond = "serves BETWEEN 4 AND 6"
+ servingOr = append(servingOr, sq.Expr("serves BETWEEN 4 AND 6"))
case 3:
- cond = "serves BETWEEN 6 AND 8"
+ servingOr = append(servingOr, sq.Expr("serves BETWEEN 6 AND 8"))
case 4:
- cond = "serves > 8"
+ servingOr = append(servingOr, sq.Gt{"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)
+ if len(servingOr) > 0 {
+ query = query.Where(servingOr)
}
- // Define columns to select
- columns := []string{
- "r.id",
- }
-
- // Create search vector query with SAFE parameterization
- var orderBy string = ""
- var searchQuery string = ""
-
+ // Handle search with full-text search and ILIKE fallback
if filters.Search != "" {
spl := strings.Split(filters.Search, " ")
var cleaned []string
- // Use a string replacer for safety
+ // Sanitize search terms
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))
+ q := strings.TrimSpace(replacer.Replace(term))
+ if q != "" {
+ cleaned = append(cleaned, q+":*") // Add prefix matching
}
}
- if len(ilikeConditions) > 0 {
- searchCondition = fmt.Sprintf("(%s OR %s)", searchCondition, strings.Join(ilikeConditions, " OR "))
+ 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))
}
-
- 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, ","))
- }
+ // Exclude deleted recipes
+ query = query.Where(sq.Eq{"deleted": false})
- // 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)
+ sql, args, err := query.ToSql()
if err != nil {
- return []int{}, fmt.Errorf("failed to query recipes: %w", err)
+ return nil, fmt.Errorf("Failed to build query: %w", err)
}
- defer rows.Close()
+ // Execute query using SQLX
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)
+ if err = r.db.Select(&ids, sql, args...); err != nil {
+ return nil, fmt.Errorf("Failed to query recipes: %w", err)
}
return ids, nil
@@ -677,28 +639,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
@@ -712,28 +672,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
@@ -784,15 +745,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
@@ -805,41 +775,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
diff --git a/internal/infrastructure/database/repository/user_repository.go b/internal/infrastructure/database/repository/user_repository.go
index 12098b9..ba5236f 100644
--- a/internal/infrastructure/database/repository/user_repository.go
+++ b/internal/infrastructure/database/repository/user_repository.go
@@ -4,12 +4,14 @@ import (
"database/sql"
"fmt"
+ sq "github.com/Masterminds/squirrel"
domain "github.com/haydenhargreaves/Potion/internal/domain/user"
+ "github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
)
type UserRepository struct {
- db *sql.DB
+ db *sqlx.DB
}
// Compile-time check to ensure the UserRepository implements domain.UserRepository
@@ -17,7 +19,7 @@ var _ domain.UserRepository = (*UserRepository)(nil)
// NewUserRepository creates a user repository object which is used by the user service to access
// the database. Any user related database operations will take place in this repository.
-func NewUserRepository(db *sql.DB) domain.UserRepository {
+func NewUserRepository(db *sqlx.DB) domain.UserRepository {
return &UserRepository{db: db}
}
@@ -30,42 +32,37 @@ func NewUserRepository(db *sql.DB) domain.UserRepository {
// best results, pair this function with the GetGoogleUser which will return the user if it can find
// it.
func (r *UserRepository) CreateGoogleUser(googleUserInfo *domain.GoogleUserInfo, googleRefreshToken string) (domain.User, error) {
- tx, err := r.db.Begin()
- if err != nil {
- return domain.User{}, err
- }
-
if googleUserInfo == nil {
return domain.User{}, fmt.Errorf("Google user info provided was nil")
}
- var user domain.User
- query := `INSERT INTO users
- (GoogleId, Name, Email, ImageUrl, GoogleRefreshToken)
- VALUES ($1, $2, $3, $4, $5) RETURNING *;`
+ psql := sq.StatementBuilder.PlaceholderFormat(sq.Dollar)
- if err := tx.QueryRow(
- query,
- googleUserInfo.Id,
- googleUserInfo.Name,
- googleUserInfo.Email,
- googleUserInfo.Picture,
- googleRefreshToken,
- ).Scan(
- &user.Id,
- &user.GoogleId,
- &user.Name,
- &user.Email,
- &user.ImageUrl,
- &user.GoogleRefreshToken,
- &user.Created,
- ); err != nil {
- tx.Rollback()
- return domain.User{}, err
+ query := psql.
+ Insert("users").
+ Columns(
+ "googleid",
+ "name",
+ "email",
+ "imageurl",
+ "googlerefreshtoken").
+ Values(
+ googleUserInfo.Id,
+ googleUserInfo.Name,
+ googleUserInfo.Email,
+ googleUserInfo.Picture,
+ googleRefreshToken,
+ ).
+ Suffix("RETURNING *")
+
+ _sql, args, err := query.ToSql()
+ if err != nil {
+ return domain.User{}, fmt.Errorf("Failed to construct sql query: %w", err)
}
- if err := tx.Commit(); err != nil {
- return domain.User{}, err
+ var user domain.User
+ if err := r.db.Get(&user, _sql, args...); err != nil {
+ return domain.User{}, fmt.Errorf("Failed to create user: %w", err)
}
return user, nil
@@ -75,23 +72,26 @@ func (r *UserRepository) CreateGoogleUser(googleUserInfo *domain.GoogleUserInfo,
// function is used when a user logs in with Google to prevent duplicate entries from being made. If
// no user is found, this function will return a null pointer but not an error.
func (r *UserRepository) GetGoogleUser(googleId string) (*domain.User, error) {
- var user domain.User
- query := `SELECT * FROM users WHERE GoogleId = $1`
+ psql := sq.StatementBuilder.PlaceholderFormat(sq.Dollar)
- if err := r.db.QueryRow(query, googleId).Scan(
- &user.Id,
- &user.GoogleId,
- &user.Name,
- &user.Email,
- &user.ImageUrl,
- &user.GoogleRefreshToken,
- &user.Created,
- ); err != nil {
- // If no user was found, don't error, just return
+ query := psql.
+ Select("*").
+ From("users").
+ Where(sq.Eq{
+ "GoogleId": googleId,
+ })
+
+ _sql, args, err := query.ToSql()
+ if err != nil {
+ return nil, fmt.Errorf("Failed to construct sql query: %w", err)
+ }
+
+ var user domain.User
+ if err := r.db.Get(&user, _sql, args...); err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
- return nil, err
+ return nil, fmt.Errorf("Failed to get Google user: %w", err)
}
return &user, nil
@@ -102,18 +102,19 @@ func (r *UserRepository) GetGoogleUser(googleId string) (*domain.User, error) {
// Callers are responsible for protecting against double nil results. Any errors will be bubbled
// to the caller.
func (r *UserRepository) GetUser(id int) (*domain.User, error) {
- query := "SELECT * FROM users WHERE id = $1"
+ psql := sq.StatementBuilder.PlaceholderFormat(sq.Dollar)
+ query := psql.
+ Select("*").
+ From("users").
+ Where(sq.Eq{"id": id})
+
+ _sql, args, err := query.ToSql()
+ if err != nil {
+ return nil, fmt.Errorf("Failed to construct sql query: %w", err)
+ }
var user domain.User
- if err := r.db.QueryRow(query, id).Scan(
- &user.Id,
- &user.GoogleId,
- &user.Name,
- &user.Email,
- &user.ImageUrl,
- &user.GoogleRefreshToken,
- &user.Created,
- ); err != nil {
+ if err := r.db.Get(&user, _sql, args...); err != nil {
// If no user was found, don't error, just return
if err == sql.ErrNoRows {
return nil, nil
diff --git a/internal/infrastructure/logging/loggers/database_logger.go b/internal/infrastructure/logging/loggers/database_logger.go
index df4160a..c57cd6d 100644
--- a/internal/infrastructure/logging/loggers/database_logger.go
+++ b/internal/infrastructure/logging/loggers/database_logger.go
@@ -1,21 +1,21 @@
package loggers
import (
- "database/sql"
"fmt"
"github.com/haydenhargreaves/Potion/internal/infrastructure/logging"
+ "github.com/jmoiron/sqlx"
)
type DatabaseLogger struct {
- db *sql.DB
+ db *sqlx.DB
table string
filter logging.LogLevel
}
var _ logging.Logger = (*DatabaseLogger)(nil)
-func NewDatabaseLogger(conn *sql.DB, table string, filter logging.LogLevel) (logging.Logger, error) {
+func NewDatabaseLogger(conn *sqlx.DB, table string, filter logging.LogLevel) (logging.Logger, error) {
if conn == nil {
return &DatabaseLogger{}, fmt.Errorf("Connection is nil, something is very wrong.")
}
@@ -45,7 +45,7 @@ func NewDatabaseLogger(conn *sql.DB, table string, filter logging.LogLevel) (log
// tableExists queries a database connection and returns whether the table name provided
// exists on the table.
-func tableExists(conn *sql.DB, tableName string) (bool, error) {
+func tableExists(conn *sqlx.DB, tableName string) (bool, error) {
var exists bool
err := conn.QueryRow(`
SELECT EXISTS (
diff --git a/web/src/components/forms/InstructionList.tsx b/web/src/components/forms/InstructionList.tsx
index ed977ab..c14d43d 100644
--- a/web/src/components/forms/InstructionList.tsx
+++ b/web/src/components/forms/InstructionList.tsx
@@ -1,6 +1,6 @@
import { Reorder } from "motion/react";
import InstructionElement from "./InstructionElement";
-import { useEffect, type Dispatch, type SetStateAction } from "react";
+import { type Dispatch, type SetStateAction } from "react";
import type { RecipeInstruction } from "../../types/recipe";
import type { RecipeValidationEntry } from "../../pages/Create";
diff --git a/web/src/pages/Recipe.tsx b/web/src/pages/Recipe.tsx
index 469ce18..00a5fa1 100644
--- a/web/src/pages/Recipe.tsx
+++ b/web/src/pages/Recipe.tsx
@@ -127,8 +127,12 @@ export default function RecipePage() {