Compare commits
No commits in common. "master" and "refactor/react" have entirely different histories.
master
...
refactor/r
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,4 +1,3 @@
|
||||
/flake.lock
|
||||
/.env
|
||||
/*.dump
|
||||
/*.log
|
||||
|
||||
@ -2,9 +2,6 @@ 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
|
||||
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/haydenhargreaves/Potion/internal/app/server"
|
||||
)
|
||||
import "github.com/haydenhargreaves/Potion/internal/app/server"
|
||||
|
||||
const PORT = 3000
|
||||
|
||||
func main() {
|
||||
s := server.Init(PORT).Setup()
|
||||
defer s.DB.Close()
|
||||
|
||||
s.Start()
|
||||
}
|
||||
|
||||
6
go.mod
6
go.mod
@ -15,7 +15,6 @@ 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,14 +24,9 @@ require (
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
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
|
||||
|
||||
16
go.sum
16
go.sum
@ -1,8 +1,5 @@
|
||||
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=
|
||||
@ -51,7 +48,6 @@ 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=
|
||||
@ -61,22 +57,16 @@ github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVI
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o=
|
||||
github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM=
|
||||
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
||||
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=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8=
|
||||
github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
@ -84,17 +74,12 @@ 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=
|
||||
@ -110,7 +95,6 @@ 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=
|
||||
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@ -31,13 +32,12 @@ func (s *Server) GoogleCallbackHandlerV2(ctx *gin.Context) {
|
||||
domain := s.deps.EnvironmentConfig.FrontendDomain
|
||||
|
||||
if jwt, err := s.deps.AuthService.GoogleAuthSuccess(state, code); err != nil {
|
||||
redirectUrl := fmt.Sprintf("%s/v2/web/login?error=%s", domain, url.QueryEscape(err.Error()))
|
||||
ctx.Redirect(http.StatusSeeOther, redirectUrl)
|
||||
url := fmt.Sprintf("%s/v2/web/login?error=%s", domain, url.QueryEscape(err.Error()))
|
||||
ctx.Redirect(http.StatusSeeOther, url)
|
||||
} else {
|
||||
// Pass JWT via query param - frontend will set the cookie
|
||||
// This bypasses cross-origin cookie issues with Cloudflare/proxies
|
||||
redirectUrl := fmt.Sprintf("%s/v2/web/auth/callback?token=%s", domain, url.QueryEscape(jwt))
|
||||
ctx.Redirect(http.StatusSeeOther, redirectUrl)
|
||||
url := fmt.Sprintf("%s/v2/web/home", domain)
|
||||
s.SetCookie(ctx, "jwt_token", jwt, time.Hour*24*7)
|
||||
ctx.Redirect(http.StatusSeeOther, url)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -43,8 +43,8 @@ func (s *Server) SetCookie(ctx *gin.Context, name, value string, duration time.D
|
||||
value,
|
||||
maxAge,
|
||||
path,
|
||||
"gophernest.net",
|
||||
true,
|
||||
".gophernest.net", // or your backend domain / parent
|
||||
true, // secure
|
||||
httpOnly,
|
||||
)
|
||||
case "dev":
|
||||
|
||||
@ -2,13 +2,13 @@ package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
domain "github.com/haydenhargreaves/Potion/internal/domain/server"
|
||||
"github.com/haydenhargreaves/Potion/internal/infrastructure/logging"
|
||||
)
|
||||
|
||||
// DepedencyInjectionMiddleware injects the dependencies into the context set. This is a middleware
|
||||
@ -77,13 +77,14 @@ func JwtAuthMiddleWare(jwtSecretKey []byte) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func RecoveryMiddleware(logs []logging.Logger) gin.HandlerFunc {
|
||||
func RecoveryMiddleware() gin.HandlerFunc {
|
||||
|
||||
return func(ctx *gin.Context) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
// Log the panic with stack trace
|
||||
err := fmt.Errorf("panic recovered: %v\n%s", r, debug.Stack())
|
||||
logging.LogAll(logs, logging.LogLevelFatal, "[PANIC RECOVERY] %s\n", err)
|
||||
log.Printf("[PANIC RECOVERY] %s", err)
|
||||
|
||||
ctx.JSON(http.StatusOK, gin.H{
|
||||
"status": http.StatusOK,
|
||||
|
||||
@ -3,12 +3,10 @@ package server
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
domain "github.com/haydenhargreaves/Potion/internal/domain/server"
|
||||
"github.com/haydenhargreaves/Potion/internal/infrastructure/logging"
|
||||
)
|
||||
|
||||
// JwtAuthMiddlewareV2 is responsible for protecting routes. Anything that may go wrong
|
||||
@ -66,9 +64,9 @@ func JwtAuthMiddlewareV2(jwtSecretKey []byte) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// JwtOptionalAuthMiddlewareV2 is responsible for collecting user data for routes where
|
||||
// authentication is optional. Meaning: if the use is not logged in, this function does
|
||||
// not fail or return, it simply does nothing. But if the user is logged in, then the
|
||||
// JwtOptionalAuthMiddlewareV2 is responsible for collecting user data for routes where
|
||||
// authentication is optional. Meaning: if the use is not logged in, this function does
|
||||
// not fail or return, it simply does nothing. But if the user is logged in, then the
|
||||
// 'userId' and 'userEmail' context values are set.
|
||||
//
|
||||
// e.g., `userIdAny, exists := ctx.Get("userId")`
|
||||
@ -98,34 +96,3 @@ func JwtOptionalAuthMiddlewareV2(jwtSecretKey []byte) gin.HandlerFunc {
|
||||
ctx.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func LoggingMiddleware(logs []logging.Logger) gin.HandlerFunc {
|
||||
// TODO: Need traces using IDs?
|
||||
return func(ctx *gin.Context) {
|
||||
start := time.Now()
|
||||
|
||||
ctx.Next()
|
||||
|
||||
var (
|
||||
status int = ctx.Writer.Status()
|
||||
latency string = time.Since(start).String()
|
||||
client string = ctx.ClientIP()
|
||||
method string = ctx.Request.Method
|
||||
path string = ctx.Request.URL.Path
|
||||
)
|
||||
|
||||
// TODO: Add color to status
|
||||
|
||||
format := "%d | %-14s | %15s | %-9s \"%s\""
|
||||
logging.LogAll(
|
||||
logs,
|
||||
logging.LogLevelInfo,
|
||||
format,
|
||||
status,
|
||||
latency,
|
||||
client,
|
||||
method,
|
||||
path,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,7 +7,6 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
domain "github.com/haydenhargreaves/Potion/internal/domain/recipe"
|
||||
domainUser "github.com/haydenhargreaves/Potion/internal/domain/user"
|
||||
)
|
||||
|
||||
// GetRecipeOfTheWeekHandler fetchs the current recipe of the week and returns it.
|
||||
@ -97,15 +96,6 @@ func (s *Server) SearchRecipeHandlerV2(ctx *gin.Context) {
|
||||
}
|
||||
|
||||
func (s *Server) CreateRecipeHandlerV2(ctx *gin.Context) {
|
||||
userId := getUserId(ctx)
|
||||
if userId == nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{
|
||||
"status": http.StatusBadRequest,
|
||||
"message": "[ERROR] User must be logged in to create a recipe.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
recipe, err := s.deps.RecipeService.CreateRecipe(ctx)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{
|
||||
@ -115,116 +105,9 @@ func (s *Server) CreateRecipeHandlerV2(ctx *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
_, err = s.deps.EngagementService.UserCreateRecipe(*userId, recipe.Id)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{
|
||||
"status": http.StatusBadRequest,
|
||||
"message": fmt.Sprintf("[ERROR] Failed to create recipe engagement. %s", err.Error()),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, gin.H{
|
||||
"status": http.StatusOK,
|
||||
"message": "[OK] Successfully created new recipe.",
|
||||
"recipe": recipe,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) EditRecipeHandlerV2(ctx *gin.Context) {
|
||||
s.withAuthenticatedUser(ctx, func(ctx *gin.Context, user *domainUser.User) {
|
||||
id := ctx.Param("id")
|
||||
parsedId, err := strconv.Atoi(id)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{
|
||||
"status": http.StatusBadRequest,
|
||||
"message": fmt.Sprintf("[ERROR] Failed to parse ID parameter. %s", err.Error()),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
recipe, err := s.deps.RecipeService.EditRecipe(ctx, parsedId, user.Id)
|
||||
|
||||
_, err = s.deps.EngagementService.UserEditRecipe(user.Id, recipe.Id)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{
|
||||
"status": http.StatusBadRequest,
|
||||
"message": fmt.Sprintf("[ERROR] Failed to create recipe engagement. %s", err.Error()),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, gin.H{
|
||||
"status": http.StatusOK,
|
||||
"message": "[OK] Successfully updated recipe.",
|
||||
"recipe": recipe,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) DeleteRecipeHandlerV2(ctx *gin.Context) {
|
||||
s.withAuthenticatedUser(ctx, func(ctx *gin.Context, user *domainUser.User) {
|
||||
id := ctx.Param("id")
|
||||
parsedId, err := strconv.Atoi(id)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{
|
||||
"status": http.StatusBadRequest,
|
||||
"message": fmt.Sprintf("[ERROR] Failed to parse ID parameter. %s", err.Error()),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
_, err = s.deps.EngagementService.UserDeleteRecipe(user.Id, parsedId)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{
|
||||
"status": http.StatusBadRequest,
|
||||
"message": fmt.Sprintf("[ERROR] Failed to create recipe engagement. %s", err.Error()),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.deps.RecipeService.DeleteRecipe(user.Id, parsedId); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{
|
||||
"status": http.StatusBadRequest,
|
||||
"message": fmt.Sprintf("[ERROR] Failed to delete recipe. %s", err.Error()),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, gin.H{
|
||||
"status": http.StatusOK,
|
||||
"message": "[OK] Successfully deleted recipe.",
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) IsRecipeOwnerV2(ctx *gin.Context) {
|
||||
userId := getUserId(ctx)
|
||||
|
||||
id := ctx.Param("id")
|
||||
parsedId, err := strconv.Atoi(id)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{
|
||||
"owner": false,
|
||||
"status": http.StatusBadRequest,
|
||||
"message": fmt.Sprintf("[ERROR] Failed to parse ID parameter. %s", err.Error()),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
isOwner, err := s.deps.RecipeService.IsRecipeOwner(userId, parsedId)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{
|
||||
"owner": false,
|
||||
"status": http.StatusBadRequest,
|
||||
"message": fmt.Sprintf("[ERROR] Failed to determine is user is recipe owner.", err.Error()),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, gin.H{
|
||||
"owner": isOwner,
|
||||
"status": http.StatusOK,
|
||||
"message": "[OK] Successfully determined recipe ownership status.",
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,12 +1,10 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/a-h/templ/examples/integration-gin/gintemplrenderer"
|
||||
"github.com/gin-contrib/cors"
|
||||
@ -15,39 +13,28 @@ import (
|
||||
domain "github.com/haydenhargreaves/Potion/internal/domain/server"
|
||||
"github.com/haydenhargreaves/Potion/internal/infrastructure/auth"
|
||||
"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"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
port int
|
||||
Router *gin.Engine
|
||||
config cors.Config
|
||||
DB *sqlx.DB
|
||||
deps domain.InjectedDependencies
|
||||
logs []logging.Logger
|
||||
cleanupFuncs []func() error
|
||||
port int
|
||||
Router *gin.Engine
|
||||
config cors.Config
|
||||
DB *sql.DB
|
||||
deps domain.InjectedDependencies
|
||||
}
|
||||
|
||||
// Init initializes the server with the provided port. CORS settings are defined here.
|
||||
// A pointer to a server object is returned which allows for method chaining.
|
||||
func Init(port int) *Server {
|
||||
server := &Server{
|
||||
Router: gin.New(), // Not default anymore, to allow for custom logger
|
||||
port: port,
|
||||
config: cors.DefaultConfig(),
|
||||
logs: []logging.Logger{},
|
||||
cleanupFuncs: []func() error{},
|
||||
Router: gin.Default(),
|
||||
port: port,
|
||||
config: cors.DefaultConfig(),
|
||||
}
|
||||
|
||||
// Default logger which logs everything
|
||||
server.logs = append(server.logs, loggers.NewConsoleLogger(logging.LogLevelTrace))
|
||||
|
||||
// Some stuff for templ rendering
|
||||
// TODO: Remove this
|
||||
htmlRenderer := server.Router.HTMLRender
|
||||
server.Router.HTMLRender = &gintemplrenderer.HTMLTemplRenderer{FallbackHtmlRenderer: htmlRenderer}
|
||||
|
||||
@ -62,59 +49,32 @@ func Init(port int) *Server {
|
||||
server.config.AllowCredentials = true
|
||||
server.Router.Use(cors.New(server.config))
|
||||
|
||||
// We can use release mode since we don't need Gin's logging
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
|
||||
return server
|
||||
}
|
||||
|
||||
// Start starts the server on the port provided when the server was initialized
|
||||
func (s *Server) Start() {
|
||||
logging.LogAll(s.logs, logging.LogLevelDebug, "Server started on :%d\n", s.port)
|
||||
|
||||
// Create channel that only listens for SIGINT and SIGTERM
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
go func() {
|
||||
if err := s.Router.Run(fmt.Sprintf(":%d", s.port)); err != nil {
|
||||
logging.LogAll(s.logs, logging.LogLevelFatal, "Server failed: %s\n", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Block until we get a message on the quit channel
|
||||
<-quit
|
||||
logging.LogAll(s.logs, logging.LogLevelInfo, "Shutting down server...\n")
|
||||
|
||||
s.cleanup()
|
||||
logging.LogAll(s.logs, logging.LogLevelInfo, "Server exited\n")
|
||||
s.Router.Run(fmt.Sprintf(":%d", s.port))
|
||||
}
|
||||
|
||||
func (s *Server) cleanup() {
|
||||
for _, cleanup := range s.cleanupFuncs {
|
||||
// NOTE: Ignoring error
|
||||
cleanup()
|
||||
}
|
||||
if s.DB != nil {
|
||||
s.DB.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: (9/4/2025) Abstract these functions and cleanup. This is fucking messy...
|
||||
// TODO: (1/26/2026) Abstract these functions and cleanup. This is fucking messy... still
|
||||
// TODO: (9/4/2025) Abstract these functions and cleanup. This is fucking messy...
|
||||
func (s *Server) Setup() *Server {
|
||||
// SETUP THE ENVIRONMENT CONFIGURATION
|
||||
cfg, err := domain.LoadEnvironment()
|
||||
if err != nil {
|
||||
logging.LogAll(s.logs, logging.LogLevelFatal, err.Error())
|
||||
panic(err.Error())
|
||||
}
|
||||
if cfg == nil {
|
||||
logging.LogAll(s.logs, logging.LogLevelFatal, "Environment configuration is nil, crashing.")
|
||||
panic("Environment configuration is nil, crashing.")
|
||||
}
|
||||
|
||||
logging.LogAll(s.logs, logging.LogLevelDebug, "env: %+v\n", cfg)
|
||||
if cfg.Environment == "dev" {
|
||||
gin.SetMode(gin.DebugMode)
|
||||
} else if cfg.Environment == "prod" {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
} else {
|
||||
gin.SetMode(gin.TestMode)
|
||||
}
|
||||
|
||||
// SETUP GOOGLE AUTH
|
||||
var (
|
||||
@ -132,7 +92,7 @@ func (s *Server) Setup() *Server {
|
||||
auth.NewGoogleConfig(redirectUrl, clientId, clientSecret, scope)
|
||||
|
||||
// SETUP DATABASE
|
||||
db, err := sqlx.Open("postgres", cfg.DatabaseUrl)
|
||||
db, err := sql.Open("postgres", cfg.DatabaseUrl)
|
||||
if err != nil {
|
||||
panic("Could not connect to database: " + err.Error())
|
||||
}
|
||||
@ -143,26 +103,6 @@ func (s *Server) Setup() *Server {
|
||||
|
||||
s.DB = db
|
||||
|
||||
// TODO: Implement environment here for logging file
|
||||
|
||||
if cfg.LogFilePath != "" {
|
||||
fileLogger, cleanup, err := loggers.NewFileLogger(cfg.LogFilePath, logging.LogLevelDebug)
|
||||
if err != nil {
|
||||
logging.LogAll(s.logs, logging.LogLevelWarning, "Failed to create file logger. %s\n", err.Error())
|
||||
} else {
|
||||
logging.LogAll(s.logs, logging.LogLevelDebug, "Initialized file logger on file '%s'\n", cfg.LogFilePath)
|
||||
s.logs = append(s.logs, fileLogger)
|
||||
s.cleanupFuncs = append(s.cleanupFuncs, cleanup)
|
||||
}
|
||||
}
|
||||
|
||||
databaseLogger, err := loggers.NewDatabaseLogger(s.DB, "logs", logging.LogLevelInfo)
|
||||
if err != nil {
|
||||
logging.LogAll(s.logs, logging.LogLevelWarning, "Failed to create database logger. %s\n", err.Error())
|
||||
} else {
|
||||
s.logs = append(s.logs, databaseLogger)
|
||||
}
|
||||
|
||||
// SETUP JWT
|
||||
jwtSecret := []byte(cfg.JwtSecret)
|
||||
|
||||
@ -171,7 +111,7 @@ func (s *Server) Setup() *Server {
|
||||
recipeRepo := repository.NewRecipeRepository(s.DB)
|
||||
engagementRepo := repository.NewEngagementRepository(s.DB)
|
||||
userService := service.NewUserService(userRepo)
|
||||
authService := service.NewAuthService(userRepo, jwtSecret, s.logs)
|
||||
authService := service.NewAuthService(userRepo, jwtSecret)
|
||||
recipeService := service.NewRecipeService(recipeRepo, engagementRepo)
|
||||
engagementService := service.NewEngagementService(engagementRepo, recipeRepo)
|
||||
|
||||
@ -184,7 +124,9 @@ func (s *Server) Setup() *Server {
|
||||
}
|
||||
|
||||
// Apply middleware
|
||||
s.Router.Use(gin.Recovery(), RecoveryMiddleware(s.logs), LoggingMiddleware(s.logs))
|
||||
s.Router.Use(RecoveryMiddleware())
|
||||
// NOTE: No longer running on every connection!
|
||||
// s.Router.Use(JwtAuthMiddleWare(jwtSecret))
|
||||
|
||||
// Redirect index to home page: Update this as needed
|
||||
s.Router.GET("/", func(ctx *gin.Context) { ctx.Redirect(http.StatusSeeOther, domain.WEB_HOME) })
|
||||
@ -259,13 +201,10 @@ func (s *Server) Setup() *Server {
|
||||
|
||||
// ---- VERSION 2 ROUTES ---- //
|
||||
router_api_v2 := router_v2.Group(domain.API)
|
||||
router_api_v2.POST("/recipe", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.CreateRecipeHandlerV2)
|
||||
router_api_v2.GET("/recipe/:id", JwtOptionalAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.GetRecipeHandlerV2)
|
||||
router_api_v2.PUT("/recipe/:id", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.EditRecipeHandlerV2)
|
||||
router_api_v2.GET("/recipe/of-the-week", JwtOptionalAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.GetRecipeOfTheWeekHandlerV2)
|
||||
router_api_v2.POST("/recipe/search", JwtOptionalAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.SearchRecipeHandlerV2)
|
||||
router_api_v2.DELETE("/recipe/:id", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.DeleteRecipeHandlerV2)
|
||||
router_api_v2.GET("/recipe/:id/is-owner", JwtOptionalAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.IsRecipeOwnerV2)
|
||||
router_api_v2.POST("/recipe", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.CreateRecipeHandlerV2)
|
||||
|
||||
router_api_v2.GET("/auth/login", s.GetGoogleAuthUrlHandlerV2)
|
||||
router_api_v2.GET("/auth/callback", s.GoogleCallbackHandlerV2)
|
||||
@ -280,21 +219,14 @@ func (s *Server) Setup() *Server {
|
||||
router_api_v2.GET("/user/recipes/made", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.GetAuthenicatedUserMadeRecipesV2)
|
||||
router_api_v2.GET("/user/recipes/viewed", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.GetAuthenicatedUserViewedRecipesV2)
|
||||
|
||||
router_api_v2.GET("/protected", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), func(ctx *gin.Context) {
|
||||
ctx.JSON(http.StatusOK, gin.H{"msg": "YAY"})
|
||||
})
|
||||
|
||||
router_api_v2.POST("/engagement/view/:id", JwtOptionalAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.EngagementViewRecipeHandlerV2)
|
||||
router_api_v2.POST("/engagement/share/:id", JwtOptionalAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.EngagementShareRecipeHandlerV2)
|
||||
router_api_v2.POST("/engagement/favorite/:id", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.EngagementFavoriteRecipeHandlerV2)
|
||||
router_api_v2.POST("/engagement/make/:id", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.EngagementMakeRecipeHandlerV2)
|
||||
|
||||
if cfg.Environment == "dev" {
|
||||
s.debugDisplayRoutes()
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Server) debugDisplayRoutes() {
|
||||
for _, route := range s.Router.Routes() {
|
||||
format := "%-8s %s"
|
||||
logging.LogAll(s.logs, logging.LogLevelDebug, format, route.Method, route.Path)
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,7 +10,6 @@ import (
|
||||
domainServer "github.com/haydenhargreaves/Potion/internal/domain/server"
|
||||
domain "github.com/haydenhargreaves/Potion/internal/domain/user"
|
||||
"github.com/haydenhargreaves/Potion/internal/infrastructure/auth"
|
||||
"github.com/haydenhargreaves/Potion/internal/infrastructure/logging"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
@ -32,7 +31,6 @@ import (
|
||||
type AuthService struct {
|
||||
userRepository domain.UserRepository
|
||||
jwtSecret []byte
|
||||
logs []logging.Logger
|
||||
}
|
||||
|
||||
// Compile-time check to ensure the AuthService implements domain.AuthService
|
||||
@ -40,11 +38,10 @@ var _ domainAuth.AuthService = (*AuthService)(nil)
|
||||
|
||||
// NewAuthService creates a user service object which can be passed into the context. The service
|
||||
// requires a user repository which it will use to hit the database when needed.
|
||||
func NewAuthService(userRepository domain.UserRepository, jwtSecret []byte, logs []logging.Logger) domainAuth.AuthService {
|
||||
func NewAuthService(userRepository domain.UserRepository, jwtSecret []byte) domainAuth.AuthService {
|
||||
return &AuthService{
|
||||
userRepository: userRepository,
|
||||
jwtSecret: jwtSecret,
|
||||
logs: logs,
|
||||
}
|
||||
}
|
||||
|
||||
@ -127,53 +124,3 @@ func generateJwt(userId int, email string, jwtSecret []byte) (string, error) {
|
||||
|
||||
return tokenString, nil
|
||||
}
|
||||
|
||||
// import (
|
||||
// "bytes"
|
||||
// "time"
|
||||
//
|
||||
// "github.com/gin-gonic/gin"
|
||||
// "github.com/google/uuid"
|
||||
// "golang.org/x/exp/slog" // or your logging.Logger
|
||||
// )
|
||||
//
|
||||
// // LoggerMiddleware logs HTTP requests with structured fields
|
||||
// func LoggerMiddleware(logger *slog.Logger) gin.HandlerFunc {
|
||||
// return func(c *gin.Context) {
|
||||
// // Generate request ID for tracing
|
||||
// reqID := uuid.New().String()
|
||||
// start := time.Now()
|
||||
//
|
||||
// // Capture request body (if enabled)
|
||||
// var reqBody []byte
|
||||
// if c.Request.ContentLength > 0 && c.Request.Body != nil {
|
||||
// reqBody, _ = io.ReadAll(c.Request.Body)
|
||||
// c.Request.Body.Close()
|
||||
// c.Request.Body = io.NopCloser(bytes.NewBuffer(reqBody))
|
||||
// }
|
||||
//
|
||||
// // Log request start
|
||||
// logger.Info("request started",
|
||||
// slog.String("req_id", reqID),
|
||||
// slog.String("method", c.Request.Method),
|
||||
// slog.String("path", c.Request.URL.Path),
|
||||
// slog.Int("content_length", int(c.Request.ContentLength)),
|
||||
// slog.String("user_agent", c.Request.UserAgent()),
|
||||
// )
|
||||
//
|
||||
// // Process request
|
||||
// c.Next()
|
||||
//
|
||||
// // Log request completion
|
||||
// duration := time.Since(start)
|
||||
// logger.Info("request completed",
|
||||
// slog.String("req_id", reqID),
|
||||
// slog.Int("status", c.Writer.Status()),
|
||||
// slog.String("method", c.Request.Method),
|
||||
// slog.String("path", c.Request.URL.Path),
|
||||
// slog.Duration("duration", duration),
|
||||
// slog.Int("size", c.Writer.Size()),
|
||||
// slog.Any("req_body", string(reqBody)), // truncate if too big
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
|
||||
@ -122,48 +122,6 @@ func (s *EngagementService) UserShareRecipe(userId, recipeId int) (domain.Engage
|
||||
return s.engagementRepository.AddUserEntityEngagement(userId, recipeId, message, domain.EngagementShared)
|
||||
}
|
||||
|
||||
// UserCreateRecipe requires a user ID and a recipe ID to create an engagement record in the database.
|
||||
// A message will be generated using the recipe data and then used to add a make engagement to the
|
||||
// database.
|
||||
func (s *EngagementService) UserCreateRecipe(userId, recipeId int) (domain.Engagement, error) {
|
||||
recipe, err := s.recipeRepository.GetRecipe(recipeId, &userId)
|
||||
if err != nil {
|
||||
return domain.Engagement{}, err
|
||||
}
|
||||
|
||||
message := fmt.Sprintf("Created \"%s\"", recipe.Title)
|
||||
|
||||
return s.engagementRepository.AddUserEntityEngagement(userId, recipeId, message, domain.EngagementCreated)
|
||||
}
|
||||
|
||||
// UserDeleteRecipe requires a user ID and a recipe ID to create an engagement record in the database.
|
||||
// A message will be generated using the recipe data and then used to add a make engagement to the
|
||||
// database.
|
||||
func (s *EngagementService) UserDeleteRecipe(userId, recipeId int) (domain.Engagement, error) {
|
||||
recipe, err := s.recipeRepository.GetRecipe(recipeId, &userId)
|
||||
if err != nil {
|
||||
return domain.Engagement{}, err
|
||||
}
|
||||
|
||||
message := fmt.Sprintf("Deleted \"%s\"", recipe.Title)
|
||||
|
||||
return s.engagementRepository.AddUserEntityEngagement(userId, recipeId, message, domain.EngagementDeleted)
|
||||
}
|
||||
|
||||
// UserEditRecipe requires a user ID and a recipe ID to create an engagement record in the database.
|
||||
// A message will be generated using the recipe data and then used to add a make engagement to the
|
||||
// database.
|
||||
func (s *EngagementService) UserEditRecipe(userId, recipeId int) (domain.Engagement, error) {
|
||||
recipe, err := s.recipeRepository.GetRecipe(recipeId, &userId)
|
||||
if err != nil {
|
||||
return domain.Engagement{}, err
|
||||
}
|
||||
|
||||
message := fmt.Sprintf("Edited \"%s\"", recipe.Title)
|
||||
|
||||
return s.engagementRepository.AddUserEntityEngagement(userId, recipeId, message, domain.EngagementEdited)
|
||||
}
|
||||
|
||||
// GetUserEngagement returns a list of the users most recent engagement entries. The number of records
|
||||
// is determined by the limit passed into this function. The results are sorted, newest-to-oldest.
|
||||
func (s *EngagementService) GetUserEngagement(userId, limit int) ([]domain.Engagement, error) {
|
||||
|
||||
@ -30,7 +30,7 @@ func NewRecipeService(recipeRepository domain.RecipeRepository, engagementReposi
|
||||
|
||||
// CreateRecipe creates a recipe in the database using the recipe repository. This function requires
|
||||
// all the data to be present, though validation does not occur in this function. However, the UI
|
||||
// will enforce validation, as will the database. Errors will be returned to the caller when they
|
||||
// will enforce validation, as will the database. Errors will be returned to the called when they
|
||||
// occur.
|
||||
//
|
||||
// TODO: Implement validation in the API.
|
||||
@ -78,60 +78,85 @@ func (s *RecipeService) CreateRecipe(ctx *gin.Context) (*domain.Recipe, error) {
|
||||
}
|
||||
|
||||
return &recipe, nil
|
||||
}
|
||||
|
||||
func (s *RecipeService) EditRecipe(ctx *gin.Context, recipeId, userId int) (*domain.Recipe, error) {
|
||||
var req domain.EditRecipeRequest
|
||||
|
||||
if err := ctx.ShouldBindJSON(&req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if recipeId != req.Id {
|
||||
return nil, fmt.Errorf("[ERROR] Mismatched recipe IDs provided. Given %d and %d.", recipeId, req.Id)
|
||||
}
|
||||
|
||||
recipe := domain.Recipe{
|
||||
Id: recipeId,
|
||||
Title: req.Title,
|
||||
Description: req.Description,
|
||||
Instructions: req.Instructions,
|
||||
Serves: req.Serves,
|
||||
Difficulty: req.Difficulty,
|
||||
Duration: req.Duration,
|
||||
Category: req.Category,
|
||||
Ingredients: req.Ingredients,
|
||||
Sections: req.Sections,
|
||||
}
|
||||
|
||||
if err := s.recipeRepository.EditRecipe(&recipe, userId); err != nil {
|
||||
return &recipe, err
|
||||
}
|
||||
|
||||
// Update the tags
|
||||
if len(req.Tags) > 0 {
|
||||
if err := s.recipeRepository.UpdateRecipeTags(recipe, req.Tags); err != nil {
|
||||
return &recipe, fmt.Errorf("Failed to attach/create tags. %s\n", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
return &recipe, nil
|
||||
}
|
||||
|
||||
// DeleteRecipe deletes a recipe in the database using the recipe repository. This function requires
|
||||
// the userId of the requesting user - to ensure the user is the owner of the recipe. Any errors will
|
||||
// be returned to caller when/if they occur.
|
||||
func (s *RecipeService) DeleteRecipe(userId, recipeId int) error {
|
||||
recipe, err := s.recipeRepository.GetRecipe(recipeId, &userId)
|
||||
if recipe == nil || err != nil {
|
||||
return fmt.Errorf("Recipe does not exist or has been relocated. Please try again. %s", err.Error())
|
||||
}
|
||||
|
||||
if recipe.UserId != userId {
|
||||
return fmt.Errorf("User id does not match. Do you own the target recipe?")
|
||||
}
|
||||
|
||||
return s.recipeRepository.DeleteRecipe(recipeId, userId)
|
||||
// title := ctx.PostForm("title")
|
||||
// description := ctx.PostForm("description")
|
||||
// preparation := ctx.PostForm("preparation-time")
|
||||
// cook := ctx.PostForm("cook-time")
|
||||
// serving := ctx.PostForm("serving-size")
|
||||
// category := ctx.PostForm("category")
|
||||
// difficulty := ctx.PostForm("difficulty")
|
||||
// ingredients := ctx.PostFormArray("ingredients")
|
||||
// quantity := ctx.PostFormArray("quantity")
|
||||
// instructions := ctx.PostFormArray("instructions")
|
||||
// tags := strings.Split(ctx.PostForm("tags"), ",")
|
||||
// userId := ctx.MustGet("userId").(int)
|
||||
//
|
||||
// // Have to get the image differently
|
||||
// image, err := ctx.FormFile("image")
|
||||
// if err != nil && !errors.Is(err, http.ErrMissingFile) {
|
||||
// // Error getting image
|
||||
// }
|
||||
//
|
||||
// // Convert to proper values
|
||||
// servingInt, _ := strconv.Atoi(serving)
|
||||
// difficultyInt, _ := strconv.Atoi(difficulty)
|
||||
// prepInt, _ := strconv.Atoi(preparation)
|
||||
// cookInt, _ := strconv.Atoi(cook)
|
||||
//
|
||||
// var ingredientSlice []domain.RecipeIngredient
|
||||
// for i := range len(ingredients) {
|
||||
// if strings.TrimSpace(ingredients[i]) != "" {
|
||||
// ins := domain.RecipeIngredient{
|
||||
// Name: ingredients[i],
|
||||
// Quantity: quantity[i],
|
||||
// }
|
||||
//
|
||||
// ingredientSlice = append(ingredientSlice, ins)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// var instructionSlice []string
|
||||
// for _, ins := range instructions {
|
||||
// if ins != "" {
|
||||
// instructionSlice = append(instructionSlice, ins)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // Create the recipe
|
||||
// recipe := domain.Recipe{
|
||||
// Title: title,
|
||||
// Description: description,
|
||||
// Instructions: instructionSlice,
|
||||
// Serves: servingInt,
|
||||
// Difficulty: difficultyInt,
|
||||
// Duration: domain.RecipeDuration{
|
||||
// Total: prepInt + cookInt,
|
||||
// Prep: prepInt,
|
||||
// Cook: cookInt,
|
||||
// },
|
||||
// Category: domain.RecipeMeal(category),
|
||||
// Ingredients: ingredientSlice,
|
||||
// UserId: userId,
|
||||
// Created: time.Now(),
|
||||
// }
|
||||
//
|
||||
// if err := s.recipeRepository.CreateRecipe(&recipe); err != nil {
|
||||
// return &recipe, err
|
||||
// }
|
||||
//
|
||||
// // TODO: Upload the image
|
||||
// if image != nil {
|
||||
// }
|
||||
//
|
||||
// // Create the tags
|
||||
// if len(tags) > 0 {
|
||||
// if err := s.recipeRepository.CreateRecipeTags(recipe, tags); err != nil {
|
||||
// return &recipe, fmt.Errorf("Failed to attach/create tags. %s\n", err.Error())
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// return &recipe, nil
|
||||
}
|
||||
|
||||
// GetRecipe will get a recipe via its ID. Any errors will be bubbled to the caller. Furthermore,
|
||||
@ -246,15 +271,3 @@ func (s *RecipeService) GetRecipeOfTheWeek(userId *int) (*domain.Recipe, error)
|
||||
|
||||
return s.recipeRepository.GetRecipe(*id, userId)
|
||||
}
|
||||
|
||||
// IsRecipeOwner takes an optional userId and a recipeId. If the userId is nil (not given) this
|
||||
// function will return false. Otherwise, it will query the database to find out of the user is
|
||||
// the owner of the recipe. Any error will be bubbled to the caller.
|
||||
func (s *RecipeService) IsRecipeOwner(userId *int, recipeId int) (bool, error) {
|
||||
// No user, obviously not the user.
|
||||
if userId == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return s.recipeRepository.IsRecipeOwner(*userId, recipeId)
|
||||
}
|
||||
|
||||
@ -14,9 +14,6 @@ const (
|
||||
EngagementShared EngagementType = "shared"
|
||||
EngagementReviewed EngagementType = "reviewed"
|
||||
EngagementRated EngagementType = "rated"
|
||||
EngagementCreated EngagementType = "created"
|
||||
EngagementDeleted EngagementType = "deleted"
|
||||
EngagementEdited EngagementType = "edited"
|
||||
)
|
||||
|
||||
// Engagement is the database model of a user engagement. There is no need to map to a different
|
||||
|
||||
@ -7,8 +7,5 @@ type EngagementService interface {
|
||||
UserFavoriteRecipe(userId, recipeId int) (Engagement, error)
|
||||
UserMakeRecipe(userId, recipeId int) (Engagement, error)
|
||||
UserShareRecipe(userId, recipeId int) (Engagement, error)
|
||||
UserCreateRecipe(userId, recipeId int) (Engagement, error)
|
||||
UserDeleteRecipe(userId, recipeId int) (Engagement, error)
|
||||
UserEditRecipe(userId, recipeId int) (Engagement, error)
|
||||
GetUserEngagement(userId, limit int) ([]Engagement, error)
|
||||
}
|
||||
|
||||
@ -124,7 +124,6 @@ type Recipe struct {
|
||||
Created time.Time
|
||||
Tags []Tag
|
||||
Favorite bool // Per requesting user
|
||||
Deleted bool
|
||||
}
|
||||
|
||||
// SearchFilters is a model which represents the required filters to complete a recipe search.
|
||||
@ -156,7 +155,7 @@ type RecipeTag struct {
|
||||
Created time.Time
|
||||
}
|
||||
|
||||
// TODO: Document
|
||||
// TODO: Comment
|
||||
type CreateRecipeRequest struct {
|
||||
Title string
|
||||
Description string
|
||||
@ -169,18 +168,3 @@ type CreateRecipeRequest struct {
|
||||
Sections []RecipeIngredientSection
|
||||
Tags []string
|
||||
}
|
||||
|
||||
// TODO Document
|
||||
type EditRecipeRequest struct {
|
||||
Id int
|
||||
Title string
|
||||
Description string
|
||||
Instructions []RecipeInstruction
|
||||
Serves int
|
||||
Difficulty int
|
||||
Duration RecipeDuration
|
||||
Category RecipeMeal
|
||||
Ingredients []RecipeIngredient
|
||||
Sections []RecipeIngredientSection
|
||||
Tags []string
|
||||
}
|
||||
|
||||
@ -2,17 +2,13 @@ package domain
|
||||
|
||||
type RecipeRepository interface {
|
||||
CreateRecipe(recipe *Recipe) error
|
||||
EditRecipe(recipe *Recipe, userId 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)
|
||||
CreateRecipeTags(recipe Recipe, tags []string) error
|
||||
UpdateRecipeTags(recipe Recipe, tags []string) error
|
||||
GetUserRecipesIds(userId int) ([]int, error)
|
||||
GetUserFavoriteRecipesIds(userId int) ([]int, error)
|
||||
GetRecipeTags(recipe *Recipe) error
|
||||
GetRecipeFavorite(recipe *Recipe, userId int) error
|
||||
GetRecipeOfTheWeekId(userId *int) (*int, error)
|
||||
IsRecipeOwner(userId, recipeId int) (bool, error)
|
||||
}
|
||||
|
||||
@ -6,8 +6,6 @@ import (
|
||||
|
||||
type RecipeService interface {
|
||||
CreateRecipe(ctx *gin.Context) (*Recipe, error)
|
||||
EditRecipe(ctx *gin.Context, recipeId, userId int) (*Recipe, error)
|
||||
DeleteRecipe(userId, recipeId int) error
|
||||
GetRecipe(id int, userId *int) (*Recipe, error)
|
||||
SearchRecipes(filters SearchFilters, userId *int, favorites bool) ([]Recipe, error)
|
||||
GetUserRecipes(userId int) ([]Recipe, error)
|
||||
@ -15,5 +13,4 @@ type RecipeService interface {
|
||||
GetUserViewedRecipes(userId, limit int) ([]Recipe, error)
|
||||
GetUserMadeRecipes(userId, limit int) ([]Recipe, error)
|
||||
GetRecipeOfTheWeek(userId *int) (*Recipe, error)
|
||||
IsRecipeOwner(userId *int, recipeId int) (bool, error)
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ package domain
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
@ -10,21 +11,19 @@ import (
|
||||
domainRecipe "github.com/haydenhargreaves/Potion/internal/domain/recipe"
|
||||
domainUser "github.com/haydenhargreaves/Potion/internal/domain/user"
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/kelseyhightower/envconfig"
|
||||
)
|
||||
|
||||
// EnvironmentConfig stores the configuration of the environment. Anything loaded from the .env
|
||||
// or docker environment will be stored here and can be accessed from the InjectedDependencies
|
||||
// struct, which this is attached to.
|
||||
type EnvironmentConfig struct {
|
||||
GoogleClientId string `envconfig:"GOOGLE_CLIENT_ID" required:"true"`
|
||||
GoogleClientSecret string `envconfig:"GOOGLE_CLIENT_SECRET" required:"true"`
|
||||
JwtSecret string `envconfig:"JWT_SECRET" required:"true"`
|
||||
DatabaseUrl string `envconfig:"DATABASE_URL" required:"true"`
|
||||
Domain string `envconfig:"DOMAIN" required:"true"`
|
||||
FrontendDomain string `envconfig:"FRONTEND_DOMAIN" required:"true"`
|
||||
Environment string `envconfig:"ENVIRONMENT" required:"true"`
|
||||
LogFilePath string `envconfig:"LOG_FILE_PATH" required:"false"`
|
||||
GoogleClientId string
|
||||
GoogleClientSecret string
|
||||
JwtSecret string
|
||||
DatabaseUrl string
|
||||
Environment string
|
||||
Domain string
|
||||
FrontendDomain string
|
||||
}
|
||||
|
||||
// InjectedDependencies is a collection of dependencies that are injected into the application. They
|
||||
@ -52,16 +51,87 @@ func IsLoggedIn(ctx *gin.Context) bool {
|
||||
return id && email
|
||||
}
|
||||
|
||||
// LoadEnvironment loads the environment values from either an .env file or docker environment.
|
||||
// LoadEnvironment loads the environment values from either an .env file or docker environment. In
|
||||
// the event that required fields are not provided, an error will return and the caller should handle
|
||||
// the missing value or panic. Toggles between 'dev', 'prod', etc are also handled by this method,
|
||||
// the values can be access assuming they are the proper values based on the provided environment.
|
||||
func LoadEnvironment() (*EnvironmentConfig, error) {
|
||||
// NOTE: Does the error return matter?
|
||||
godotenv.Load(".env")
|
||||
|
||||
cfg := &EnvironmentConfig{}
|
||||
if err := envconfig.Process("", cfg); err != nil {
|
||||
return nil, fmt.Errorf("Failed to load environment: %w", err)
|
||||
err := godotenv.Load(".env")
|
||||
if err != nil {
|
||||
fmt.Printf("No .env file found or error loading .env: %v. Relying on system environment variables.", err)
|
||||
}
|
||||
|
||||
env := os.Getenv("ENVIRONMENT")
|
||||
if env == "" {
|
||||
return nil, fmt.Errorf("ENVIRONMENT environment variable is required.")
|
||||
}
|
||||
|
||||
googleClientId := os.Getenv("GOOGLE_CLIENT_ID")
|
||||
if googleClientId == "" {
|
||||
return nil, fmt.Errorf("GOOGLE_CLIENT_ID environment variable is required.")
|
||||
}
|
||||
|
||||
googleClientSecret := os.Getenv("GOOGLE_CLIENT_SECRET")
|
||||
if googleClientSecret == "" {
|
||||
return nil, fmt.Errorf("GOOGLE_CLIENT_SECRET environment variable is required.")
|
||||
}
|
||||
|
||||
jwtSecret := os.Getenv("JWT_SECRET")
|
||||
if jwtSecret == "" {
|
||||
return nil, fmt.Errorf("JWT_SECRET environment variable is required.")
|
||||
}
|
||||
|
||||
var domain string
|
||||
var frontendDomain string
|
||||
if env == "dev" {
|
||||
domain = os.Getenv("DOMAIN_DEV")
|
||||
if domain == "" {
|
||||
return nil, fmt.Errorf("DOMAIN_DEV environment variable is required when ENVIRONMENT is 'dev'.")
|
||||
}
|
||||
frontendDomain = os.Getenv("FRONTEND_DOMAIN_DEV")
|
||||
if frontendDomain == "" {
|
||||
return nil, fmt.Errorf("FRONTEND_DOMAIN_DEV environment variable is required when ENVIRONMENT is 'dev'.")
|
||||
}
|
||||
} else if env == "prod" {
|
||||
domain = os.Getenv("DOMAIN_PROD")
|
||||
if domain == "" {
|
||||
return nil, fmt.Errorf("DOMAIN_PROD environment variable is required when ENVIRONMENT is 'prod'.")
|
||||
}
|
||||
frontendDomain = os.Getenv("FRONTEND_DOMAIN_PROD")
|
||||
if frontendDomain == "" {
|
||||
return nil, fmt.Errorf("FRONTEND_DOMAIN_PROD environment variable is required when ENVIRONMENT is 'dev'.")
|
||||
}
|
||||
} else {
|
||||
return nil, fmt.Errorf("ENVIRONMENT environment variable is required and must be 'dev' or 'prod'.")
|
||||
}
|
||||
|
||||
var dbUrl string
|
||||
if env == "dev" {
|
||||
dbUrl = os.Getenv("DATABASE_URL_DEV")
|
||||
if dbUrl == "" {
|
||||
return nil, fmt.Errorf("DATABASE_URL_DEV environment variable is required when ENVIRONMENT is 'dev'.")
|
||||
}
|
||||
} else if env == "prod" {
|
||||
dbUrl = os.Getenv("DATABASE_URL_PROD")
|
||||
if dbUrl == "" {
|
||||
return nil, fmt.Errorf("DATABASE_URL_PROD environment variable is required when ENVIRONMENT is 'prod'.")
|
||||
}
|
||||
} else {
|
||||
return nil, fmt.Errorf("ENVIRONMENT environment variable is required and must be 'dev' or 'prod'.")
|
||||
}
|
||||
|
||||
cfg := &EnvironmentConfig{
|
||||
GoogleClientId: googleClientId,
|
||||
GoogleClientSecret: googleClientSecret,
|
||||
JwtSecret: jwtSecret,
|
||||
DatabaseUrl: dbUrl,
|
||||
Environment: env,
|
||||
Domain: domain,
|
||||
FrontendDomain: frontendDomain,
|
||||
}
|
||||
|
||||
fmt.Printf("Environment Config: %+v\n", cfg)
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
-- Author: Hayden Hargreaves (hhargreaves2006@gmail.com)
|
||||
-- Desc: Create the recipe of the week stored procedure.
|
||||
-- Date: 07/26/2025, 1/10/2026
|
||||
-- Date: 07/26/2025
|
||||
|
||||
CREATE OR REPLACE PROCEDURE calculate_recipe_of_the_week_procedure()
|
||||
LANGUAGE plpgsql
|
||||
@ -20,9 +20,6 @@ BEGIN
|
||||
NOW()
|
||||
FROM
|
||||
Engagements e
|
||||
JOIN Recipes r
|
||||
ON r.Id = e.Entity
|
||||
AND r.Deleted = FALSE
|
||||
WHERE
|
||||
e.Created >= NOW() - INTERVAL '7 days'
|
||||
AND e.Entity IS NOT NULL
|
||||
|
||||
@ -1,13 +0,0 @@
|
||||
-- Author: Hayden Hargreaves (hhargreaves2006@gmail.com)
|
||||
-- Desc: Updated the E_ENGAGEMENT enum to contain created and deleted.
|
||||
-- Date: 01/10/2026
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TYPE E_ENGAGEMENT
|
||||
ADD VALUE IF NOT EXISTS 'created'; -- created recipe
|
||||
|
||||
ALTER TYPE E_ENGAGEMENT
|
||||
ADD VALUE IF NOT EXISTS 'deleted'; -- deleted recipe
|
||||
|
||||
COMMIT;
|
||||
@ -1,10 +0,0 @@
|
||||
-- Author: Hayden Hargreaves (hhargreaves2006@gmail.com)
|
||||
-- Desc: Updated the E_ENGAGEMENT enum to contain created and deleted.
|
||||
-- Date: 01/10/2026
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE recipes
|
||||
ADD COLUMN Deleted BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
COMMIT;
|
||||
@ -1,14 +0,0 @@
|
||||
-- Author: Hayden Hargreaves (hhargreaves2006@gmail.com)
|
||||
-- Desc: Update recipes table to allow larger serving sizes.
|
||||
-- Date: 01/13/2026
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE recipes
|
||||
DROP CONSTRAINT recipes_serves_check;
|
||||
|
||||
ALTER TABLE recipes
|
||||
ADD CONSTRAINT recipes_serves_check
|
||||
CHECK (serves >= 0 AND serves <= 127);
|
||||
|
||||
COMMIT;
|
||||
@ -1,14 +0,0 @@
|
||||
-- Author: Hayden Hargreaves (hhargreaves2006@gmail.com)
|
||||
-- Desc: Create the logs table.
|
||||
-- Date: 01/23/2026
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS Logs (
|
||||
Id SERIAL PRIMARY KEY NOT NULL,
|
||||
Level TEXT NOT NULL CHECK (level IN ('TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR', 'FATAL')),
|
||||
Message TEXT NOT NULL,
|
||||
Created TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
COMMIT;
|
||||
@ -1,10 +0,0 @@
|
||||
-- Author: Hayden Hargreaves (hhargreaves2006@gmail.com)
|
||||
-- Desc: Updated the E_ENGAGEMENT enum to contain edited.
|
||||
-- Date: 02/01/2026
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TYPE E_ENGAGEMENT
|
||||
ADD VALUE IF NOT EXISTS 'edited'; -- edited recipe
|
||||
|
||||
COMMIT;
|
||||
@ -10,9 +10,4 @@ psql -h "$PSQL_HOST" -U "$PSQL_USERNAME" -d "$PSQL_DATABASE" -f ./internal/infra
|
||||
psql -h "$PSQL_HOST" -U "$PSQL_USERNAME" -d "$PSQL_DATABASE" -f ./internal/infrastructure/database/migrations/008_create_favorites_table.sql
|
||||
psql -h "$PSQL_HOST" -U "$PSQL_USERNAME" -d "$PSQL_DATABASE" -f ./internal/infrastructure/database/migrations/009_create_recipe_of_the_week_table.sql
|
||||
psql -h "$PSQL_HOST" -U "$PSQL_USERNAME" -d "$PSQL_DATABASE" -f ./internal/infrastructure/database/migrations/010_create_recipe_of_the_week_procedure.sql
|
||||
psql -h "$PSQL_HOST" -U "$PSQL_USERNAME" -d "$PSQL_DATABASE" -f ./internal/infrastructure/database/migrations/011_update_engagement_enum.sql
|
||||
psql -h "$PSQL_HOST" -U "$PSQL_USERNAME" -d "$PSQL_DATABASE" -f ./internal/infrastructure/database/migrations/012_update_recipes_table_deleted_column.sql
|
||||
psql -h "$PSQL_HOST" -U "$PSQL_USERNAME" -d "$PSQL_DATABASE" -f ./internal/infrastructure/database/migrations/013_update_recipes_allow_large_servings.sql
|
||||
psql -h "$PSQL_HOST" -U "$PSQL_USERNAME" -d "$PSQL_DATABASE" -f ./internal/infrastructure/database/migrations/014_create_logs_table.sql
|
||||
psql -h "$PSQL_HOST" -U "$PSQL_USERNAME" -d "$PSQL_DATABASE" -f ./internal/infrastructure/database/migrations/015_update_engagement_enum.sql
|
||||
|
||||
|
||||
@ -7,12 +7,11 @@ import (
|
||||
"time"
|
||||
|
||||
domain "github.com/haydenhargreaves/Potion/internal/domain/engagement"
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
type EngagementRepository struct {
|
||||
db *sqlx.DB
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// Compile-time check to ensure the EngagementRepository implements domain.EngagementRepository
|
||||
@ -20,7 +19,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 *sqlx.DB) domain.EngagementRepository {
|
||||
func NewEngagementRepository(db *sql.DB) domain.EngagementRepository {
|
||||
return &EngagementRepository{db: db}
|
||||
}
|
||||
|
||||
@ -32,6 +31,7 @@ func NewEngagementRepository(db *sqlx.DB) domain.EngagementRepository {
|
||||
func (r *EngagementRepository) AddUserEngagement(userId int, message string, engagementType domain.EngagementType) (domain.Engagement, error) {
|
||||
tx, err := r.db.Begin()
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return domain.Engagement{}, err
|
||||
}
|
||||
|
||||
@ -58,6 +58,7 @@ func (r *EngagementRepository) AddUserEngagement(userId int, message string, eng
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
tx.Rollback()
|
||||
return domain.Engagement{}, err
|
||||
}
|
||||
|
||||
@ -79,6 +80,7 @@ func (r *EngagementRepository) AddUserEngagement(userId int, message string, eng
|
||||
func (r *EngagementRepository) AddUserEntityEngagement(userId, entityId int, message string, engagementType domain.EngagementType) (domain.Engagement, error) {
|
||||
tx, err := r.db.Begin()
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return domain.Engagement{}, err
|
||||
}
|
||||
|
||||
@ -105,6 +107,7 @@ func (r *EngagementRepository) AddUserEntityEngagement(userId, entityId int, mes
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
tx.Rollback()
|
||||
return domain.Engagement{}, err
|
||||
}
|
||||
|
||||
@ -138,6 +141,7 @@ func (r *EngagementRepository) AddEngagement(message string, engagementType doma
|
||||
|
||||
tx, err := r.db.Begin()
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return domain.Engagement{}, err
|
||||
}
|
||||
|
||||
@ -164,6 +168,7 @@ func (r *EngagementRepository) AddEngagement(message string, engagementType doma
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
tx.Rollback()
|
||||
return domain.Engagement{}, err
|
||||
}
|
||||
|
||||
@ -197,6 +202,7 @@ func (r *EngagementRepository) AddEntityEngagement(entityId int, message string,
|
||||
|
||||
tx, err := r.db.Begin()
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return domain.Engagement{}, err
|
||||
}
|
||||
|
||||
@ -223,6 +229,7 @@ func (r *EngagementRepository) AddEntityEngagement(entityId int, message string,
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
tx.Rollback()
|
||||
return domain.Engagement{}, err
|
||||
}
|
||||
|
||||
@ -332,6 +339,7 @@ func (r *EngagementRepository) GetUserEngagementFiltered(userId, limit int, enga
|
||||
func (r *EngagementRepository) UserFavoriteRecipeToggle(userId, recipeId int) (bool, error) {
|
||||
tx, err := r.db.Begin()
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return false, err
|
||||
}
|
||||
|
||||
|
||||
@ -3,18 +3,16 @@ 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
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// Compile-time check to ensure the RecipeRepository implements domain.RecipeRepository
|
||||
@ -22,7 +20,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 *sqlx.DB) domain.RecipeRepository {
|
||||
func NewRecipeRepository(db *sql.DB) domain.RecipeRepository {
|
||||
return &RecipeRepository{db: db}
|
||||
}
|
||||
|
||||
@ -33,7 +31,27 @@ 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 {
|
||||
// Convert data into a readable format
|
||||
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
|
||||
// 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
|
||||
@ -54,46 +72,28 @@ func (r *RecipeRepository) CreateRecipe(recipe *domain.Recipe) error {
|
||||
instructions[i] = instruction.Content
|
||||
}
|
||||
|
||||
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)
|
||||
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
|
||||
}
|
||||
|
||||
var id int
|
||||
if err := r.db.Get(&id, _sql, args...); err != nil {
|
||||
return fmt.Errorf("Failed to create recipe: %w", err)
|
||||
if err := tx.Commit(); err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
// Set the new ID
|
||||
@ -102,119 +102,15 @@ func (r *RecipeRepository) CreateRecipe(recipe *domain.Recipe) error {
|
||||
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("Recipe must contain an ID. Cannot edit unknown recipe.")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
return fmt.Errorf("Failed to construct query: %w", err)
|
||||
}
|
||||
|
||||
result, err := r.db.Exec(_sql, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to update recipe: %w", err)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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, userId int) error {
|
||||
psql := sq.StatementBuilder.PlaceholderFormat(sq.Dollar)
|
||||
|
||||
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 fmt.Errorf("Failed to build delete query: %w", err)
|
||||
}
|
||||
|
||||
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("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
|
||||
query := ` SELECT
|
||||
id, title, description, instructions, serves, difficulty, duration, category, ingredients,
|
||||
userid, modified, created, deleted
|
||||
userid, modified, created
|
||||
FROM recipes
|
||||
WHERE id = $1 AND deleted = false;
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
var durationBytes []byte
|
||||
@ -226,6 +122,7 @@ func (r *RecipeRepository) GetRecipe(id int, userId *int) (*domain.Recipe, error
|
||||
&recipe.Id,
|
||||
&recipe.Title,
|
||||
&recipe.Description,
|
||||
// pq.Array(&instructions),
|
||||
&instructions,
|
||||
&recipe.Serves,
|
||||
&recipe.Difficulty,
|
||||
@ -235,12 +132,8 @@ func (r *RecipeRepository) GetRecipe(id int, userId *int) (*domain.Recipe, error
|
||||
&recipe.UserId,
|
||||
&recipe.Modified,
|
||||
&recipe.Created,
|
||||
&recipe.Deleted,
|
||||
); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, err
|
||||
}
|
||||
return nil, fmt.Errorf("Failed to location recipe (id: %d) in database: %s", id, err.Error())
|
||||
return nil, fmt.Errorf("Failed to location recipe in database: %s", err.Error())
|
||||
}
|
||||
|
||||
// Parse duration
|
||||
@ -295,15 +188,12 @@ func (r *RecipeRepository) GetRecipe(id int, userId *int) (*domain.Recipe, error
|
||||
// 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 && err != sql.ErrNoRows {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@ -330,168 +220,182 @@ func isBitActive(bits, pos int) bool {
|
||||
//
|
||||
// TODO: Pagination is required, to provide infinite scroll.
|
||||
//
|
||||
// TODO: This does not work in the current build, the DB does not return valid values.
|
||||
//
|
||||
// 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) {
|
||||
// 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
|
||||
// Compute meals type filters (there are 7 bits)
|
||||
var mealConditions []string
|
||||
for i := range 7 {
|
||||
if isBitActive(filters.MealType, i) {
|
||||
mealCategories = append(mealCategories, string(domain.ParseMeal(i)))
|
||||
mealConditions = append(mealConditions, fmt.Sprintf("category = '%s'", 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
|
||||
// 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:
|
||||
timeOr = append(timeOr, sq.Lt{"(duration->>'total')::int": 15})
|
||||
cond = "(duration->>'total')::int < 15"
|
||||
case 1:
|
||||
timeOr = append(timeOr, sq.Expr("(duration->>'total')::int BETWEEN 15 AND 30"))
|
||||
cond = "(duration->>'total')::int BETWEEN 15 AND 30"
|
||||
case 2:
|
||||
timeOr = append(timeOr, sq.Expr("(duration->>'total')::int BETWEEN 30 AND 60"))
|
||||
cond = "(duration->>'total')::int BETWEEN 30 AND 60"
|
||||
case 3:
|
||||
timeOr = append(timeOr, sq.Expr("(duration->>'total')::int BETWEEN 60 AND 120"))
|
||||
cond = "(duration->>'total')::int BETWEEN 60 AND 120"
|
||||
case 4:
|
||||
timeOr = append(timeOr, sq.Gt{"(duration->>'total')::int": 120})
|
||||
cond = "(duration->>'total')::int > 120"
|
||||
}
|
||||
timeConditions = append(timeConditions, cond)
|
||||
}
|
||||
}
|
||||
|
||||
if len(timeOr) > 0 {
|
||||
query = query.Where(timeOr)
|
||||
}
|
||||
|
||||
// Compute and add difficulty filters (5 bit options)
|
||||
var difficulties []int
|
||||
// Compute difficulty filters (there are 5 bits)
|
||||
var difficultyConditions []string
|
||||
for i := range 5 {
|
||||
if isBitActive(filters.Difficulty, i) {
|
||||
difficulties = append(difficulties, i+1)
|
||||
cond := fmt.Sprintf("difficulty = '%d'", i+1)
|
||||
difficultyConditions = append(difficultyConditions, cond)
|
||||
}
|
||||
}
|
||||
|
||||
if len(difficulties) > 0 {
|
||||
query = query.Where(sq.Eq{"difficulty": difficulties})
|
||||
}
|
||||
|
||||
// Compute and add serving size filters (5 bit options)
|
||||
var servingOr sq.Or
|
||||
// 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:
|
||||
servingOr = append(servingOr, sq.Expr("serves BETWEEN 1 AND 2"))
|
||||
cond = "serves BETWEEN 1 AND 2"
|
||||
case 1:
|
||||
servingOr = append(servingOr, sq.Expr("serves BETWEEN 2 AND 4"))
|
||||
cond = "serves BETWEEN 2 AND 4"
|
||||
case 2:
|
||||
servingOr = append(servingOr, sq.Expr("serves BETWEEN 4 AND 6"))
|
||||
cond = "serves BETWEEN 4 AND 6"
|
||||
case 3:
|
||||
servingOr = append(servingOr, sq.Expr("serves BETWEEN 6 AND 8"))
|
||||
cond = "serves BETWEEN 6 AND 8"
|
||||
case 4:
|
||||
servingOr = append(servingOr, sq.Gt{"serves": 8})
|
||||
cond = "serves > 8"
|
||||
}
|
||||
servingConditions = append(servingConditions, cond)
|
||||
}
|
||||
}
|
||||
|
||||
if len(servingOr) > 0 {
|
||||
query = query.Where(servingOr)
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Handle search with full-text search and ILIKE fallback
|
||||
// Define columns to select. More fields can be added if the full text search is required
|
||||
columns := []string{
|
||||
"r.id",
|
||||
}
|
||||
|
||||
// TODO: Need to add these to the query
|
||||
|
||||
// FROM ... JOIN favorites f ON f.recipeId = r.id
|
||||
// WHERE ... AND f.userId = 3
|
||||
|
||||
// Create search vector query
|
||||
var orderBy string = ""
|
||||
if filters.Search != "" {
|
||||
spl := strings.Split(filters.Search, " ")
|
||||
var cleaned []string
|
||||
|
||||
// Sanitize search terms
|
||||
// Use a string replacer, each word in the query will be passed through this
|
||||
replacer := strings.NewReplacer(
|
||||
"'", "",
|
||||
"-", "",
|
||||
"&", "",
|
||||
"|", "",
|
||||
"!", "",
|
||||
":", "",
|
||||
"(", "",
|
||||
")", "",
|
||||
)
|
||||
|
||||
for _, term := range spl {
|
||||
q := strings.TrimSpace(replacer.Replace(term))
|
||||
for i := range len(spl) {
|
||||
q := strings.TrimSpace(replacer.Replace(spl[i]))
|
||||
|
||||
if q != "" {
|
||||
cleaned = append(cleaned, q+":*") // Add prefix matching
|
||||
cleaned = append(cleaned, q)
|
||||
}
|
||||
}
|
||||
|
||||
if len(cleaned) > 0 {
|
||||
vectorQuery := strings.Join(cleaned, " | ")
|
||||
vector_query := 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{}
|
||||
conditions = append(
|
||||
conditions,
|
||||
fmt.Sprintf("r.search_vector @@ to_tsquery('english', '%s')", vector_query),
|
||||
)
|
||||
|
||||
// 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))
|
||||
}
|
||||
template := `
|
||||
ORDER BY
|
||||
ts_rank(r.search_vector, to_tsquery('english', '%s')) DESC,
|
||||
ts_rank_cd(r.search_vector, to_tsquery('english', '%s')) DESC
|
||||
`
|
||||
orderBy = fmt.Sprintf(template, vector_query, vector_query)
|
||||
}
|
||||
|
||||
// Exclude deleted recipes
|
||||
query = query.Where(sq.Eq{"deleted": false})
|
||||
// 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, ","),
|
||||
)
|
||||
|
||||
sql, args, err := query.ToSql()
|
||||
// Add new favorite condition to the conditions list
|
||||
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
|
||||
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 := r.db.Query(query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Failed to build query: %w", err)
|
||||
return []int{}, fmt.Errorf("failed to query recipes: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
// Execute query using SQLX
|
||||
var ids []int
|
||||
if err = r.db.Select(&ids, sql, args...); err != nil {
|
||||
return nil, fmt.Errorf("Failed to query recipes: %w", err)
|
||||
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
|
||||
@ -506,6 +410,7 @@ func (r *RecipeRepository) SearchRecipes(filters domain.SearchFilters, userId *i
|
||||
func (r *RecipeRepository) CreateRecipeTags(recipe domain.Recipe, tags []string) error {
|
||||
tx, err := r.db.Begin()
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
@ -555,110 +460,33 @@ func (r *RecipeRepository) CreateRecipeTags(recipe domain.Recipe, tags []string)
|
||||
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(userId int) ([]int, error) {
|
||||
psql := sq.StatementBuilder.PlaceholderFormat(sq.Dollar)
|
||||
func (r *RecipeRepository) GetUserRecipesIds(user_id int) ([]int, error) {
|
||||
query := `
|
||||
SELECT id
|
||||
FROM recipes
|
||||
WHERE userid = $1
|
||||
ORDER BY created DESC;
|
||||
`
|
||||
|
||||
query := psql.
|
||||
Select("id").
|
||||
From("recipes").
|
||||
Where(sq.Eq{
|
||||
"userid": userId,
|
||||
"deleted": false,
|
||||
}).
|
||||
OrderBy("created DESC")
|
||||
|
||||
_sql, args, err := query.ToSql()
|
||||
rows, err := r.db.Query(query, user_id)
|
||||
if err != nil {
|
||||
return []int{}, fmt.Errorf("Failed to construct SQL query: %w", err)
|
||||
return nil, fmt.Errorf("Failed to query DB for user recipes. %s\n", err.Error())
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var ids []int
|
||||
if err := r.db.Select(&ids, _sql, args...); err != nil {
|
||||
return []int{}, fmt.Errorf("Failed to get user recipes: %w", err)
|
||||
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
|
||||
@ -669,32 +497,28 @@ func (r *RecipeRepository) GetUserRecipesIds(userId int) ([]int, error) {
|
||||
// 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(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()
|
||||
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
|
||||
ORDER BY f.created DESC;
|
||||
`
|
||||
rows, err := r.db.Query(query, id)
|
||||
if err != nil {
|
||||
return []int{}, fmt.Errorf("Failed to construct SQL query: %w", err)
|
||||
return nil, fmt.Errorf("Failed to query DB for user recipes. %s\n", err.Error())
|
||||
}
|
||||
|
||||
fmt.Println(_sql)
|
||||
defer rows.Close()
|
||||
|
||||
var ids []int
|
||||
if err := r.db.Select(&ids, _sql, args...); err != nil {
|
||||
return []int{}, fmt.Errorf("Failed to get users' favorite recipes: %w", err)
|
||||
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
|
||||
@ -745,24 +569,15 @@ func (r *RecipeRepository) GetRecipeFavorite(recipe *domain.Recipe, userId int)
|
||||
return nil
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
query := `
|
||||
SELECT COUNT(*)
|
||||
FROM favorites
|
||||
WHERE recipeid = $1 AND userid = $2;
|
||||
`
|
||||
|
||||
var count int
|
||||
if err := r.db.Get(&count, _sql, args...); err != nil {
|
||||
return fmt.Errorf("Failed to get recipe favorite status: %w", err)
|
||||
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
|
||||
@ -775,57 +590,22 @@ 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) {
|
||||
psql := sq.StatementBuilder.PlaceholderFormat(sq.Dollar)
|
||||
query := `
|
||||
SELECT
|
||||
r.id
|
||||
FROM recipes r
|
||||
JOIN recipeoftheweek rw ON rw.recipeid = r.id
|
||||
ORDER BY rw.created DESC
|
||||
LIMIT 1;
|
||||
`
|
||||
|
||||
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 {
|
||||
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 nil, fmt.Errorf("Failed to location recipe in database: %s", err.Error())
|
||||
}
|
||||
|
||||
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) {
|
||||
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.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
|
||||
return &id, nil
|
||||
}
|
||||
|
||||
@ -4,14 +4,12 @@ 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 *sqlx.DB
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// Compile-time check to ensure the UserRepository implements domain.UserRepository
|
||||
@ -19,7 +17,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 *sqlx.DB) domain.UserRepository {
|
||||
func NewUserRepository(db *sql.DB) domain.UserRepository {
|
||||
return &UserRepository{db: db}
|
||||
}
|
||||
|
||||
@ -32,37 +30,44 @@ func NewUserRepository(db *sqlx.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 {
|
||||
tx.Rollback()
|
||||
return domain.User{}, err
|
||||
}
|
||||
|
||||
if googleUserInfo == nil {
|
||||
return domain.User{}, fmt.Errorf("Google user info provided was nil")
|
||||
}
|
||||
|
||||
psql := sq.StatementBuilder.PlaceholderFormat(sq.Dollar)
|
||||
var user domain.User
|
||||
query := `INSERT INTO users
|
||||
(GoogleId, Name, Email, ImageUrl, GoogleRefreshToken)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING *;`
|
||||
|
||||
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.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
|
||||
}
|
||||
|
||||
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)
|
||||
if err := tx.Commit(); err != nil {
|
||||
tx.Rollback()
|
||||
return domain.User{}, err
|
||||
}
|
||||
|
||||
return user, nil
|
||||
@ -72,26 +77,23 @@ 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) {
|
||||
psql := sq.StatementBuilder.PlaceholderFormat(sq.Dollar)
|
||||
|
||||
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 {
|
||||
query := `SELECT * FROM users WHERE GoogleId = $1`
|
||||
|
||||
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
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("Failed to get Google user: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
@ -102,19 +104,18 @@ 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) {
|
||||
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)
|
||||
}
|
||||
query := "SELECT * FROM users WHERE id = $1"
|
||||
|
||||
var user domain.User
|
||||
if err := r.db.Get(&user, _sql, args...); err != nil {
|
||||
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 no user was found, don't error, just return
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
|
||||
@ -1,63 +0,0 @@
|
||||
package logging
|
||||
|
||||
type LogLevel string
|
||||
|
||||
const (
|
||||
LogLevelTrace LogLevel = "TRACE"
|
||||
LogLevelDebug LogLevel = "DEBUG"
|
||||
LogLevelInfo LogLevel = "INFO"
|
||||
LogLevelWarning LogLevel = "WARN"
|
||||
LogLevelError LogLevel = "ERROR"
|
||||
LogLevelFatal LogLevel = "FATAL"
|
||||
)
|
||||
|
||||
// MatchFilter is called on a filter (l) with a target level to match on the filter. Match
|
||||
// means returning true of the target is greater than OR EQUAL TO the filter level. They order
|
||||
// by scale of magnitude.
|
||||
func (filter LogLevel) MatchFilter(target LogLevel) bool {
|
||||
// Define severity levels (higher number = more severe)
|
||||
severity := map[LogLevel]int{
|
||||
LogLevelTrace: 0,
|
||||
LogLevelDebug: 1,
|
||||
LogLevelInfo: 2,
|
||||
LogLevelWarning: 3,
|
||||
LogLevelError: 4,
|
||||
LogLevelFatal: 5,
|
||||
}
|
||||
|
||||
filterSeverity, filterOk := severity[filter]
|
||||
targetSeverity, targetOk := severity[target]
|
||||
|
||||
if !filterOk || !targetOk {
|
||||
return false
|
||||
}
|
||||
|
||||
return targetSeverity >= filterSeverity
|
||||
}
|
||||
|
||||
const (
|
||||
// Background colors
|
||||
BgBlack = "\033[40m"
|
||||
BgRed = "\033[41m"
|
||||
BgGreen = "\033[42m"
|
||||
BgYellow = "\033[43m"
|
||||
BgBlue = "\033[44m"
|
||||
BgMagenta = "\033[45m"
|
||||
BgCyan = "\033[46m"
|
||||
BgWhite = "\033[47m"
|
||||
|
||||
// Reset
|
||||
Reset = "\033[0m"
|
||||
)
|
||||
|
||||
type Logger interface {
|
||||
Log(level LogLevel, format string, v ...any)
|
||||
}
|
||||
|
||||
// LogAll takes all of the inputs for a single logger and executes the logging operation
|
||||
// on each of the loggers (logs) provided. This is just a convince function.
|
||||
func LogAll(logs []Logger, level LogLevel, format string, v ...any) {
|
||||
for _, log := range logs {
|
||||
log.Log(level, format, v...)
|
||||
}
|
||||
}
|
||||
@ -1,59 +0,0 @@
|
||||
package loggers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/haydenhargreaves/Potion/internal/infrastructure/logging"
|
||||
)
|
||||
|
||||
type ConsoleLogger struct {
|
||||
writer io.Writer
|
||||
filter logging.LogLevel
|
||||
}
|
||||
|
||||
var _ logging.Logger = (*ConsoleLogger)(nil)
|
||||
|
||||
// NewConsoleLogger creates a new logger which writes directly to standard out (stdout).
|
||||
func NewConsoleLogger(filter logging.LogLevel) logging.Logger {
|
||||
return &ConsoleLogger{
|
||||
writer: os.Stdout,
|
||||
filter: filter,
|
||||
}
|
||||
}
|
||||
|
||||
// formatLevelString converts a log level string (level) into a new, formatted output string.
|
||||
// This also includes color, if the shell supports it. Otherwise, the rendering may appear odd.
|
||||
func formatLevelString(level logging.LogLevel) string {
|
||||
switch level {
|
||||
case logging.LogLevelTrace:
|
||||
return fmt.Sprintf("%s[%s]%s", logging.BgMagenta, level, logging.Reset)
|
||||
case logging.LogLevelDebug:
|
||||
return fmt.Sprintf("%s[%s]%s", logging.BgBlue, level, logging.Reset)
|
||||
case logging.LogLevelInfo:
|
||||
return fmt.Sprintf("%s[%s]%s", logging.BgGreen, level, logging.Reset)
|
||||
case logging.LogLevelWarning:
|
||||
return fmt.Sprintf("%s[%s]%s", logging.BgYellow, level, logging.Reset)
|
||||
case logging.LogLevelError:
|
||||
return fmt.Sprintf("%s[%s]%s", logging.BgRed, level, logging.Reset)
|
||||
case logging.LogLevelFatal:
|
||||
return fmt.Sprintf("%s[%s]%s", logging.BgRed, level, logging.Reset)
|
||||
}
|
||||
return fmt.Sprintf("[%s]", level)
|
||||
}
|
||||
|
||||
// Log implements the interface.
|
||||
func (l *ConsoleLogger) Log(level logging.LogLevel, format string, v ...any) {
|
||||
// level is too low, do not log
|
||||
if !l.filter.MatchFilter(level) {
|
||||
return
|
||||
}
|
||||
|
||||
timestamp := time.Now().UTC().Format("01/02/2006 - 15:04:05")
|
||||
levelStr := formatLevelString(level)
|
||||
fullFormat := fmt.Sprintf("%-18s %s | %s\n", levelStr, timestamp, format)
|
||||
bytes := fmt.Appendf(nil, fullFormat, v...)
|
||||
l.writer.Write(bytes)
|
||||
}
|
||||
@ -1,77 +0,0 @@
|
||||
package loggers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/haydenhargreaves/Potion/internal/infrastructure/logging"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
type DatabaseLogger struct {
|
||||
db *sqlx.DB
|
||||
table string
|
||||
filter logging.LogLevel
|
||||
}
|
||||
|
||||
var _ logging.Logger = (*DatabaseLogger)(nil)
|
||||
|
||||
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.")
|
||||
}
|
||||
// Ensure the DB is open
|
||||
if err := conn.Ping(); err != nil {
|
||||
return &DatabaseLogger{}, err
|
||||
}
|
||||
|
||||
// Ensure the table exists
|
||||
exists, err := tableExists(conn, table)
|
||||
if err != nil {
|
||||
return &DatabaseLogger{}, err
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return &DatabaseLogger{}, fmt.Errorf("Database table '%s' does not exist on provided connection.", table)
|
||||
}
|
||||
|
||||
logger := &DatabaseLogger{
|
||||
db: conn,
|
||||
table: table,
|
||||
filter: filter,
|
||||
}
|
||||
|
||||
return logger, nil
|
||||
}
|
||||
|
||||
// tableExists queries a database connection and returns whether the table name provided
|
||||
// exists on the table.
|
||||
func tableExists(conn *sqlx.DB, tableName string) (bool, error) {
|
||||
var exists bool
|
||||
err := conn.QueryRow(`
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = $1
|
||||
)`,
|
||||
tableName).Scan(&exists)
|
||||
return exists, err
|
||||
}
|
||||
|
||||
// Log implements the interface.
|
||||
func (l *DatabaseLogger) Log(level logging.LogLevel, format string, v ...any) {
|
||||
// level is too low, do not log
|
||||
if !l.filter.MatchFilter(level) {
|
||||
return
|
||||
}
|
||||
|
||||
message := fmt.Sprintf(format, v...)
|
||||
query := "INSERT INTO logs (level, message) VALUES ($1, $2);"
|
||||
|
||||
// Ignoring result and error, cuz what the hell would we do with them lol
|
||||
_, err := l.db.Exec(query, level, message)
|
||||
|
||||
// TODO: Remove
|
||||
if err != nil {
|
||||
println(err)
|
||||
}
|
||||
}
|
||||
@ -1,61 +0,0 @@
|
||||
package loggers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/haydenhargreaves/Potion/internal/infrastructure/logging"
|
||||
)
|
||||
|
||||
type FileLogger struct {
|
||||
writer io.Writer
|
||||
file *os.File
|
||||
filter logging.LogLevel
|
||||
}
|
||||
|
||||
var _ logging.Logger = (*FileLogger)(nil)
|
||||
|
||||
// NewFileLogger creates a new file logger, opened on the filepath provided. If any errors
|
||||
// occur, an error will be returned, along with an EMPTY logger. This is not a pointer return
|
||||
// so it will never be nil, just empty.
|
||||
//
|
||||
// This function does not close the file, cleanup function that is returned should be called
|
||||
// to close the file opened in this function.
|
||||
func NewFileLogger(filepath string, filter logging.LogLevel) (logging.Logger, func() error, error) {
|
||||
f, err := os.OpenFile(filepath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||
if err != nil {
|
||||
return &FileLogger{}, nil, err
|
||||
}
|
||||
|
||||
if f == nil {
|
||||
return &FileLogger{}, nil, fmt.Errorf("File could not be opened. File is nil.")
|
||||
}
|
||||
|
||||
logger := &FileLogger{
|
||||
writer: f,
|
||||
file: f,
|
||||
filter: filter,
|
||||
}
|
||||
|
||||
cleanup := func() error {
|
||||
return f.Close()
|
||||
}
|
||||
|
||||
return logger, cleanup, nil
|
||||
}
|
||||
|
||||
// Log implements the interface.
|
||||
func (l *FileLogger) Log(level logging.LogLevel, format string, v ...any) {
|
||||
// level is too low, do not log
|
||||
if !l.filter.MatchFilter(level) {
|
||||
return
|
||||
}
|
||||
|
||||
timestamp := time.Now().UTC().Format("01/02/2006 - 15:04:05")
|
||||
fullFormat := fmt.Sprintf("%-13s %s | %s\n", "["+level+"]", timestamp, format)
|
||||
bytes := fmt.Appendf(nil, fullFormat, v...)
|
||||
l.writer.Write(bytes)
|
||||
l.file.Sync()
|
||||
}
|
||||
1
web/.gitignore
vendored
1
web/.gitignore
vendored
@ -24,4 +24,3 @@ dist-ssr
|
||||
*.sw?
|
||||
|
||||
.env
|
||||
.vite
|
||||
|
||||
@ -13,7 +13,6 @@ import { use, type ReactNode } from 'react';
|
||||
import { AuthContext } from './context/AuthContext';
|
||||
import RecipePage from './pages/Recipe';
|
||||
import SearchPage from './pages/Search';
|
||||
import AuthCallback from './pages/AuthCallback';
|
||||
|
||||
function ProtectedRoute({ children }: { children: ReactNode }) {
|
||||
const { isLoggedIn } = use(AuthContext)
|
||||
@ -38,9 +37,6 @@ function App() {
|
||||
{/* Login page does not inherit WebLayout */}
|
||||
<Route path="/v2/web/login" element={<LoginPage />} />
|
||||
|
||||
{/* Auth callback - handles token from OAuth redirect */}
|
||||
<Route path="/v2/web/auth/callback" element={<AuthCallback />} />
|
||||
|
||||
<Route path="/v2/web" element={<WebLayout />}>
|
||||
<Route index element={<Navigate to={ROUTE_CONSTANTS.Home} replace />} />
|
||||
<Route path="home" element={<Home />} />
|
||||
|
||||
@ -1,15 +0,0 @@
|
||||
import DeleteIconSmall from "../icons/DeleteIconSmall";
|
||||
|
||||
interface DeleteButtonProps {
|
||||
clickHandler: () => void
|
||||
}
|
||||
|
||||
export default function DeleteButton({ clickHandler }: DeleteButtonProps) {
|
||||
return (
|
||||
<button onClick={clickHandler} className="flex items-center min-w-1/4 justify-center gap-x-1 rounded-lg border border-gray-300 text-gray-800 px-6 py-3 flex-grow hover:bg-gray-50 hover:border-red-300 duration-300 cursor-pointer">
|
||||
<DeleteIconSmall />
|
||||
Delete
|
||||
</button>
|
||||
);
|
||||
|
||||
}
|
||||
@ -1,15 +0,0 @@
|
||||
import EditIconSmall from "../icons/EditIconSmall";
|
||||
|
||||
interface EditButtonProps {
|
||||
clickHandler: () => void
|
||||
}
|
||||
|
||||
export default function EditButton({ clickHandler }: EditButtonProps) {
|
||||
return (
|
||||
<button onClick={clickHandler} className="flex items-center min-w-1/4hh justify-center gap-x-1 rounded-lg border border-gray-300 text-gray-800 px-6 py-3 flex-grow hover:bg-gray-50 hover:border-blue-300 duration-300 cursor-pointer">
|
||||
<EditIconSmall />
|
||||
Edit
|
||||
</button>
|
||||
);
|
||||
|
||||
}
|
||||
@ -32,7 +32,7 @@ export default function FavoriteButton({ favorite, id }: FavoriteButtonProps) {
|
||||
const result = await EngagementFavoriteRecipe(id);
|
||||
if (isApiError(result)) {
|
||||
console.error(result.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@ -43,7 +43,7 @@ export default function FavoriteButton({ favorite, id }: FavoriteButtonProps) {
|
||||
|
||||
return _favorite ? (
|
||||
<button
|
||||
className="flex items-center min-w-1/4 justify-center gap-x-1 rounded-lg border border-blue-300 bg-blue-50 text-gray-800 px-6 py-3 flex-grow hover:bg-blue-100 hover:border-blue-500 duration-300 cursor-pointer"
|
||||
className="flex items-center justify-center gap-x-1 rounded-lg border border-blue-300 bg-blue-50 text-gray-800 px-6 py-3 flex-grow hover:bg-blue-100 hover:border-blue-500 duration-300 cursor-pointer"
|
||||
onClick={() => void clickHandler()}
|
||||
>
|
||||
<svg className="h-6 text-red-500" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
|
||||
@ -30,12 +30,12 @@ export default function MadeButton({ id }: MadeButtonProps) {
|
||||
const result = await EngagementMakeRecipe(id);
|
||||
if (isApiError(result)) {
|
||||
console.error(result.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`flex items-center min-w-1/4 justify-center gap-x-1 rounded-lg border ${clicked ? "border-blue-500 text-blue-500" : "border-gray-300 text-gray-800"} px-6 py-3 flex-grow hover:bg-gray-50 hover:border-blue-300 duration-300 cursor-pointer`}
|
||||
className={`flex items-center justify-center gap-x-1 rounded-lg border ${clicked ? "border-blue-500 text-blue-500" : "border-gray-300 text-gray-800"} px-6 py-3 flex-grow hover:bg-gray-50 hover:border-blue-300 duration-300 cursor-pointer`}
|
||||
onClick={() => void clickHandler()}
|
||||
>
|
||||
<svg
|
||||
|
||||
@ -14,6 +14,7 @@ export default function ShareButton({ id }: ShareButtonProps) {
|
||||
const clickHandler = async () => {
|
||||
if (clicked) return;
|
||||
|
||||
console.log(window.location);
|
||||
// Copy first, so it feels fast
|
||||
const url = `${window.location.origin}${ROUTE_CONSTANTS.Recipe(id)}`;
|
||||
|
||||
@ -29,7 +30,7 @@ export default function ShareButton({ id }: ShareButtonProps) {
|
||||
};
|
||||
|
||||
return clicked ? (
|
||||
<button className="flex items-center min-w-1/4 justify-center gap-x-1 rounded-lg border border-green-500 text-green-500 px-6 py-3 flex-grow hover:bg-gray-50 hover:border-blue-300 duration-300">
|
||||
<button className="flex items-center justify-center gap-x-1 rounded-lg border border-green-500 text-green-500 px-6 py-3 flex-grow hover:bg-gray-50 hover:border-blue-300 duration-300">
|
||||
<svg className="h-7" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
@ -41,7 +42,7 @@ export default function ShareButton({ id }: ShareButtonProps) {
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="flex items-center min-w-1/4 justify-center gap-x-1 rounded-lg border border-gray-300 text-gray-800 px-6 py-3 flex-grow hover:bg-gray-50 hover:border-blue-300 duration-300 cursor-pointer"
|
||||
className="flex items-center justify-center gap-x-1 rounded-lg border border-gray-300 text-gray-800 px-6 py-3 flex-grow hover:bg-gray-50 hover:border-blue-300 duration-300 cursor-pointer"
|
||||
onClick={() => void clickHandler()}
|
||||
>
|
||||
<svg className="h-7" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
|
||||
@ -39,7 +39,7 @@ export default function RecipeCardLarge({ recipe }: RecipeCardLargeProps) {
|
||||
<p className="text-xs overflow-hidden whitespace-nowrap text-ellipsis">
|
||||
Serves {recipe.Serves}
|
||||
</p>
|
||||
<p className="text-sm text-wrap w-80 break-all">
|
||||
<p className="text-sm text-wrap w-80">
|
||||
{recipe.Description}
|
||||
</p>
|
||||
<div className="flex items-end justify-between">
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { Reorder } from "motion/react";
|
||||
import InstructionElement from "./InstructionElement";
|
||||
import { type Dispatch, type SetStateAction } from "react";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import type { RecipeInstruction } from "../../types/recipe";
|
||||
import type { RecipeValidationEntry } from "../../pages/Create";
|
||||
|
||||
|
||||
@ -1,7 +0,0 @@
|
||||
export default function EditIconSmall() {
|
||||
return (
|
||||
<svg className="size-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640">
|
||||
<path d="M535.6 85.7C513.7 63.8 478.3 63.8 456.4 85.7L432 110.1L529.9 208L554.3 183.6C576.2 161.7 576.2 126.3 554.3 104.4L535.6 85.7zM236.4 305.7C230.3 311.8 225.6 319.3 222.9 327.6L193.3 416.4C190.4 425 192.7 434.5 199.1 441C205.5 447.5 215 449.7 223.7 446.8L312.5 417.2C320.7 414.5 328.2 409.8 334.4 403.7L496 241.9L398.1 144L236.4 305.7zM160 128C107 128 64 171 64 224L64 480C64 533 107 576 160 576L416 576C469 576 512 533 512 480L512 384C512 366.3 497.7 352 480 352C462.3 352 448 366.3 448 384L448 480C448 497.7 433.7 512 416 512L160 512C142.3 512 128 497.7 128 480L128 224C128 206.3 142.3 192 160 192L256 192C273.7 192 288 177.7 288 160C288 142.3 273.7 128 256 128L160 128z" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
|
||||
export default function WarningIconLarge() {
|
||||
return (
|
||||
<svg className="size-18 text-red-700" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640">
|
||||
<path d="M320 112C434.9 112 528 205.1 528 320C528 434.9 434.9 528 320 528C205.1 528 112 434.9 112 320C112 205.1 205.1 112 320 112zM320 576C461.4 576 576 461.4 576 320C576 178.6 461.4 64 320 64C178.6 64 64 178.6 64 320C64 461.4 178.6 576 320 576zM231 231C221.6 240.4 221.6 255.6 231 264.9L286 319.9L231 374.9C221.6 384.3 221.6 399.5 231 408.8C240.4 418.1 255.6 418.2 264.9 408.8L319.9 353.8L374.9 408.8C384.3 418.2 399.5 418.2 408.8 408.8C418.1 399.4 418.2 384.2 408.8 374.9L353.8 319.9L408.8 264.9C418.2 255.5 418.2 240.3 408.8 231C399.4 221.7 384.2 221.6 374.9 231L319.9 286L264.9 231C255.5 221.6 240.3 221.6 231 231z" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
|
||||
export default function XIconSmall() {
|
||||
return (
|
||||
<svg className="size-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640">
|
||||
<path d="M504.6 148.5C515.9 134.9 514.1 114.7 500.5 103.4C486.9 92.1 466.7 93.9 455.4 107.5L320 270L184.6 107.5C173.3 93.9 153.1 92.1 139.5 103.4C125.9 114.7 124.1 134.9 135.4 148.5L278.3 320L135.4 491.5C124.1 505.1 125.9 525.3 139.5 536.6C153.1 547.9 173.3 546.1 184.6 532.5L320 370L455.4 532.5C466.7 546.1 486.9 547.9 500.5 536.6C514.1 525.3 515.9 505.1 504.6 491.5L361.7 320L504.6 148.5z" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@ -7,9 +7,10 @@ import { isApiError } from "../../types/api/error";
|
||||
import type { Recipe } from "../../types/recipe";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { FilterContext } from "../../context/FilterContext";
|
||||
import ROUTE_CONSTANTS from "../../types/routes";
|
||||
|
||||
interface RecipeSearchBarProps {
|
||||
// filters: SearchFilters;
|
||||
// setFilters: React.Dispatch<React.SetStateAction<SearchFilters>>;
|
||||
redirect: boolean;
|
||||
searchOnLoad: boolean;
|
||||
favorites: boolean;
|
||||
@ -29,7 +30,7 @@ export default function RecipeSearchBar({ redirect, searchOnLoad, favorites, set
|
||||
// SERVER FUNCTIONS
|
||||
const fetchSearchResults = async () => {
|
||||
if (redirect) {
|
||||
await navigate(ROUTE_CONSTANTS.Search);
|
||||
await navigate("/v2/web/search");
|
||||
return;
|
||||
}
|
||||
|
||||
@ -38,7 +39,7 @@ export default function RecipeSearchBar({ redirect, searchOnLoad, favorites, set
|
||||
if (setLoading) setLoading(true);
|
||||
|
||||
try {
|
||||
const result = await SearchRecipes({ ...filters, Favorites: favorites });
|
||||
const result = await SearchRecipes(filters);
|
||||
if (isApiError(result)) {
|
||||
console.error(result.message);
|
||||
return;
|
||||
@ -75,6 +76,14 @@ export default function RecipeSearchBar({ redirect, searchOnLoad, favorites, set
|
||||
void fetchSearchResults();
|
||||
}, [searchOnLoad]);
|
||||
|
||||
useEffect(() => {
|
||||
setFilters({
|
||||
...filters,
|
||||
Favorites: favorites
|
||||
});
|
||||
}, [favorites]);
|
||||
|
||||
|
||||
return (
|
||||
<form className="w-full px-4 my-8" onSubmit={(e) => void searchHandler(e)}>
|
||||
<div className="flex w-full gap-x-2">
|
||||
|
||||
@ -1,46 +0,0 @@
|
||||
import WarningIconLarge from "../icons/WarningIconLarge"
|
||||
import XIconSmall from "../icons/XIconSmall";
|
||||
|
||||
interface ConfirmRecipeDeleteModalProps {
|
||||
cancelHandler: () => void;
|
||||
deleteHandler: () => void;
|
||||
|
||||
}
|
||||
|
||||
export default function ConfirmRecipeDeleteModal({ cancelHandler, deleteHandler }: ConfirmRecipeDeleteModalProps) {
|
||||
|
||||
return (
|
||||
<div className="bg-black/25 fixed w-screen h-screen top-0 left-0 flex items-center justify-center select-none">
|
||||
|
||||
<div className="bg-white relative max-w-9/10 md:max-w-1/2 lg:max-w-1/4 rounded-sm broder-gray-300 flex flex-col items-center justify-evenly py-8 px-16 gap-y-4">
|
||||
|
||||
{/* Close button */}
|
||||
<button onClick={cancelHandler} className="absolute cursor-pointer top-1 right-1 p-3 duration-100 text-gray-500 hover:text-gray-600">
|
||||
<XIconSmall />
|
||||
</button>
|
||||
|
||||
<WarningIconLarge />
|
||||
<h2 className="text-lg md:text-xl"> Are you sure?</h2>
|
||||
<p className="text-gray-600 text-md text-center">
|
||||
Are you sure you want to delete this recipe? This action cannot be undone!
|
||||
</p>
|
||||
|
||||
<div className="flex gap-x-4">
|
||||
<button
|
||||
onClick={cancelHandler}
|
||||
className="py-2 px-8 bg-gray-200 rounded-sm cursor-pointer duration-300 hover:bg-gray-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={deleteHandler}
|
||||
className="py-2 px-8 bg-red-700 text-white rounded-sm cursor-pointer duration-300 hover:bg-red-800"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -4,7 +4,6 @@ import type { SearchFilters } from "../types/search";
|
||||
interface FilterContextType {
|
||||
filters: SearchFilters;
|
||||
setFilters: (filters: SearchFilters) => void;
|
||||
resetFilters: () => void;
|
||||
}
|
||||
|
||||
export const FilterContext = createContext<FilterContextType>({
|
||||
@ -17,5 +16,4 @@ export const FilterContext = createContext<FilterContextType>({
|
||||
Favorites: false,
|
||||
},
|
||||
setFilters: () => { return },
|
||||
resetFilters: () => { return },
|
||||
});
|
||||
|
||||
@ -36,7 +36,7 @@ export function FilterProvider({ children }: { children: ReactNode }) {
|
||||
}, [filters]);
|
||||
|
||||
return (
|
||||
<FilterContext value={{ filters, setFilters, resetFilters: () => setFilters(DEFAULT_FILTERS) }}>
|
||||
<FilterContext value={{ filters, setFilters }}>
|
||||
{children}
|
||||
</FilterContext>
|
||||
)
|
||||
|
||||
@ -46,7 +46,7 @@ export function useIngredients() {
|
||||
const ingredientChange = (id: string, name: "Amount" | "Unit" | "Name", value: string) => {
|
||||
setIngredients(prev =>
|
||||
prev.map(ing =>
|
||||
ing.Id === id
|
||||
ing.Id === id
|
||||
? { ...ing, [name]: name === "Amount" ? Number(value) : value }
|
||||
: ing
|
||||
)
|
||||
@ -72,7 +72,6 @@ export function useIngredients() {
|
||||
return {
|
||||
sections,
|
||||
ingredients,
|
||||
setIngredients,
|
||||
setSections,
|
||||
sectionChange,
|
||||
ingredientChange,
|
||||
|
||||
@ -34,7 +34,7 @@ export function validateCreateRecipeForm(values: CreateRecipeFormValues, dirty:
|
||||
servingSize: dirty.servingSize
|
||||
? values.servingSize !== "" &&
|
||||
Number(values.servingSize) >= 1 &&
|
||||
Number(values.servingSize) <= 127
|
||||
Number(values.servingSize) <= 16
|
||||
: true,
|
||||
category: dirty.category
|
||||
? values.category !== "" && isRecipeMeal(values.category)
|
||||
|
||||
@ -9,12 +9,12 @@ export default function WebLayout() {
|
||||
<div className="bg-gray-100 min-h-screen">
|
||||
<Navigation />
|
||||
<div className="w-full flex justify-center">
|
||||
<div className="mx-2 md:mx-0 w-full md:w-1/2 md:pt-14 min-h-screen h-fit border-l border-r
|
||||
border-gray-300 bg-white relative">
|
||||
<div className="mx-2 md:mx-0 w-full md:w-1/2 md:pt-14 min-h-screen h-fit border-l border-r border-gray-300 bg-white">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,30 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
import { useSearchParams, useNavigate } from "react-router-dom";
|
||||
import { useCookies } from "react-cookie";
|
||||
|
||||
export default function AuthCallback() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const [, setCookie] = useCookies(["jwt_token"]);
|
||||
|
||||
useEffect(() => {
|
||||
const token = searchParams.get("token");
|
||||
|
||||
if (token) {
|
||||
// Set cookie with 7 day expiration, accessible across all subdomains
|
||||
setCookie("jwt_token", token, {
|
||||
path: "/",
|
||||
domain: "gophernest.net", // shared across all subdomains
|
||||
maxAge: 60 * 60 * 24 * 7, // 7 days in seconds
|
||||
secure: true,
|
||||
sameSite: "lax",
|
||||
});
|
||||
void navigate("/v2/web/home", { replace: true });
|
||||
} else {
|
||||
// No token provided, redirect to login
|
||||
void navigate("/v2/web/login", { replace: true });
|
||||
}
|
||||
}, [searchParams, setCookie, navigate]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import Banner from "../components/Banner";
|
||||
import { isRecipeMeal, type Recipe, type RecipeInstruction } from "../types/recipe";
|
||||
import { isRecipeMeal, type RecipeInstruction } from "../types/recipe";
|
||||
import InstructionList from "../components/forms/InstructionList";
|
||||
import ValidationErrorList from "../components/forms/ValidationErrorList";
|
||||
import IngredientSection from "../components/forms/IngredientSection";
|
||||
@ -13,10 +13,10 @@ import RecipeCreateFormWrapper from "../components/inputs/RecipeCreateFormWrappe
|
||||
import RecipeCreateFormTagsInputs from "../components/inputs/RecipeCreateFormTagsInput";
|
||||
import { useIngredients } from "../hooks/useIngredients";
|
||||
import { validateCreateRecipeForm } from "../hooks/validation";
|
||||
import { CreateRecipe, EditRecipe, GetRecipe, IsRecipeOwner } from "../services/RecipeService";
|
||||
import type { CreateRecipeRequest, EditRecipeRequest } from "../types/api/recipe";
|
||||
import { isApiError, type ApiError } from "../types/api/error";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { CreateRecipe } from "../services/RecipeService";
|
||||
import type { CreateRecipeRequest } from "../types/api/recipe";
|
||||
import { isApiError } from "../types/api/error";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import ROUTE_CONSTANTS from "../types/routes";
|
||||
|
||||
// TODO: Move these
|
||||
@ -127,8 +127,9 @@ export default function Create() {
|
||||
|
||||
// Functions
|
||||
const createRecipe = async (): Promise<void> => {
|
||||
console.log({ title, description, tags, prepTime, cookTime, servingSize, category, difficulty, sections, ingredients, instructions });
|
||||
|
||||
// Exit if not valid recipe meal
|
||||
// This is a REQUIRED typescript check.
|
||||
if (!isRecipeMeal(category)) {
|
||||
console.error("[ERROR] Recipe meal is invalid.");
|
||||
return;
|
||||
@ -161,53 +162,10 @@ export default function Create() {
|
||||
await navigate(ROUTE_CONSTANTS.Recipe(response.Id));
|
||||
};
|
||||
|
||||
const editRecipe = async (): Promise<void> => {
|
||||
const recipeId = Number(editingId);
|
||||
|
||||
if (!recipeId) {
|
||||
console.error("[ERROR] Invalid reicpe ID");
|
||||
return;
|
||||
}
|
||||
|
||||
// Exit if not valid recipe meal
|
||||
// This is a REQUIRED typescript check.
|
||||
if (!isRecipeMeal(category)) {
|
||||
console.error("[ERROR] Recipe meal is invalid.");
|
||||
return;
|
||||
}
|
||||
|
||||
const recipe: EditRecipeRequest = {
|
||||
Id: recipeId,
|
||||
Title: title,
|
||||
Description: description,
|
||||
Instructions: instructions,
|
||||
Serves: Number(servingSize),
|
||||
Difficulty: Number(difficulty),
|
||||
Duration: {
|
||||
Prep: Number(prepTime),
|
||||
Cook: Number(cookTime),
|
||||
Total: Number(prepTime) + Number(cookTime)
|
||||
},
|
||||
Category: category,
|
||||
Ingredients: ingredients,
|
||||
Sections: sections,
|
||||
Tags: tags,
|
||||
};
|
||||
|
||||
const response = await EditRecipe(recipe);
|
||||
if (isApiError(response)) {
|
||||
console.error(response);
|
||||
return;
|
||||
}
|
||||
// TODO: Success toast!
|
||||
await navigate(ROUTE_CONSTANTS.Recipe(response.Id));
|
||||
}
|
||||
|
||||
// Import ingredients
|
||||
const {
|
||||
sections,
|
||||
ingredients,
|
||||
setIngredients,
|
||||
setSections,
|
||||
sectionChange,
|
||||
ingredientChange,
|
||||
@ -288,11 +246,7 @@ export default function Create() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (editingId) {
|
||||
void editRecipe();
|
||||
} else {
|
||||
void createRecipe();
|
||||
}
|
||||
void createRecipe();
|
||||
}
|
||||
|
||||
|
||||
@ -315,115 +269,26 @@ export default function Create() {
|
||||
setIsFormValid(bools_valid && ingredients_valid && instructions_valid);
|
||||
}, [validation, dirty]);
|
||||
|
||||
// EDITING IMPLEMENTATION
|
||||
const [searchParams] = useSearchParams();
|
||||
const editingId = searchParams.get("edit");
|
||||
|
||||
// Functions
|
||||
const getIsAuthor = async (recipeId: number) => {
|
||||
if (!recipeId) return;
|
||||
const response = await IsRecipeOwner(recipeId);
|
||||
if (isApiError(response)) {
|
||||
console.error(response.message);
|
||||
return;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
const setForm = (recipe: Recipe) => {
|
||||
setTitle(recipe.Title);
|
||||
setDescription(recipe.Description);
|
||||
setTags(recipe.Tags.map(tag => tag.Name));
|
||||
setPrepTime(recipe.Duration.Prep.toString());
|
||||
setCookTime(recipe.Duration.Cook.toString());
|
||||
setServingSize(recipe.Serves.toString());
|
||||
setCategory(recipe.Category);
|
||||
setDifficulty(recipe.Difficulty.toString());
|
||||
|
||||
// Generate IDs for instructions and store them
|
||||
const instructionsWithIds: RecipeInstruction[] = recipe.Instructions.map(ins => ({
|
||||
Id: crypto.randomUUID(),
|
||||
Content: ins.Content
|
||||
}));
|
||||
setInstructions(instructionsWithIds);
|
||||
|
||||
setSections(recipe.Sections);
|
||||
|
||||
// Manually set the local state, not ideal but it works
|
||||
setIngredients(recipe.Ingredients.sort((a, b) => a.SectionId.localeCompare(b.SectionId)));
|
||||
|
||||
const ingredientsDirty: Record<string, boolean> = {};
|
||||
recipe.Ingredients.forEach(ing => {
|
||||
ingredientsDirty[ing.Id] = true;
|
||||
});
|
||||
|
||||
const instructionsDirty: Record<string, boolean> = {};
|
||||
instructionsWithIds.forEach(ins => {
|
||||
instructionsDirty[ins.Id] = true;
|
||||
});
|
||||
|
||||
setDirty({
|
||||
title: true,
|
||||
description: true,
|
||||
prepTime: true,
|
||||
cookTime: true,
|
||||
servingSize: true,
|
||||
category: true,
|
||||
difficulty: true,
|
||||
ingredients: ingredientsDirty,
|
||||
instructions: instructionsDirty,
|
||||
});
|
||||
};
|
||||
|
||||
// Effects
|
||||
useEffect(() => {
|
||||
const id = Number(editingId);
|
||||
if (!id) return;
|
||||
console.debug("@validation", validation);
|
||||
}, [validation]);
|
||||
|
||||
const execute = async () => {
|
||||
const isAuthor = await getIsAuthor(id);
|
||||
|
||||
if (!isAuthor) {
|
||||
console.error("User is not the owner, and cannot edit this recipe.");
|
||||
return;
|
||||
}
|
||||
|
||||
const result: Recipe | ApiError = await GetRecipe(id);
|
||||
if (isApiError(result)) {
|
||||
console.error(result.message);
|
||||
return;
|
||||
}
|
||||
|
||||
setForm(result);
|
||||
};
|
||||
|
||||
void execute();
|
||||
}, [editingId]);
|
||||
useEffect(() => {
|
||||
console.debug("@dirty", dirty);
|
||||
}, [dirty]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Banner content={editingId ? "Edit Your Recipe" : "Create Your Masterpiece"} />
|
||||
<Banner content="Create Your Masterpiece" />
|
||||
<div className="mx-4 md:mx-16 my-8">
|
||||
<p className="mb-8">
|
||||
{editingId ? (
|
||||
<>
|
||||
Welcome back! Update your recipe by modifying any of the details below, including the recipe's name,
|
||||
description, category, duration, difficulty, ingredients, and instructions. You can add or remove
|
||||
ingredients and instruction steps using the dedicated buttons, and update the recipe image if desired.
|
||||
All required fields are marked with an <span className="text-red-500">*</span>. Once you're happy
|
||||
with your changes, hit the "Update Recipe" button to save your edits!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Welcome to the Recipe Creation Wizard! Simply fill in the details about your culinary creation,
|
||||
including the recipe's name, a description, and other specifics like its category, duration,
|
||||
and difficulty. Don't forget to dynamically add all your ingredients and instructions using
|
||||
the dedicated buttons, and feel free to upload an appealing image. All required fields are
|
||||
marked with an <span className="text-red-500">*</span>. Once everything looks perfect, just
|
||||
hit the "Create Recipe" button to share your masterpiece!
|
||||
</>
|
||||
)}
|
||||
Welcome to the Recipe Creation Wizard! Simply fill in the details about your culinary creation,
|
||||
including the recipe's name, a description, and other specifics like its category, duration,
|
||||
and difficulty. Don't forget to dynamically add all your ingredients and instructions using
|
||||
the dedicated buttons, and feel free to upload an appealing image. All required fields are
|
||||
marked with an <span className="text-red-500">*</span>. Once everything looks perfect, just hit the "Create Recipe"
|
||||
button to
|
||||
share your masterpiece!
|
||||
</p>
|
||||
<div>
|
||||
{/* Title Input */}
|
||||
@ -520,7 +385,7 @@ export default function Create() {
|
||||
error="Please enter a serving size."
|
||||
parentClasses="flex-grow w-1/3"
|
||||
min="1"
|
||||
max="127"
|
||||
max="16"
|
||||
classes={INPUT_CLASSES}
|
||||
/>
|
||||
</div>
|
||||
@ -658,7 +523,7 @@ export default function Create() {
|
||||
disabled={!isFormValid}
|
||||
className={`${isFormValid ? "bg-gradient-to-r from-blue-200 to-purple-200 cursor-pointer" : "bg-gray-200 text-gray-500 cursor-not-allowed"} w-full py-2 rounded-lg text-lg shadow-md`}
|
||||
>
|
||||
{editingId ? "Save Changes" : "Create Recipe"}
|
||||
Create Recipe
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -12,13 +12,10 @@ import { GetRecipeOfTheWeek } from "../services/RecipeService";
|
||||
import { isApiError, type ApiError } from "../types/api/error";
|
||||
import { AuthContext } from "../context/AuthContext";
|
||||
import { GetAuthenticatedUserMadeRecipes, GetAuthenticateUserViewedRecipes } from "../services/UserService";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { FilterContext } from "../context/FilterContext";
|
||||
|
||||
export default function Home() {
|
||||
// Context
|
||||
const { isLoggedIn } = use(AuthContext);
|
||||
const { resetFilters } = use(FilterContext);
|
||||
|
||||
// Page state
|
||||
const [recipeOfTheWeek, setRecipeOfTheWeek] = useState<Recipe | null>(null);
|
||||
@ -27,7 +24,6 @@ export default function Home() {
|
||||
const [viewedRecipes, setViewedRecipes] = useState<Recipe[]>([]);
|
||||
|
||||
const [error, setError] = useState<string>("");
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Fetch the recipe of the week
|
||||
useEffect(() => {
|
||||
@ -59,12 +55,6 @@ export default function Home() {
|
||||
void fetch();
|
||||
}, [isLoggedIn]);
|
||||
|
||||
const viewAllRecipesHandler = () => {
|
||||
// Clear filters
|
||||
resetFilters();
|
||||
void navigate(ROUTE_CONSTANTS.Search);
|
||||
}
|
||||
|
||||
// BUG: Prob remove
|
||||
useEffect(() => {
|
||||
if (error)
|
||||
@ -98,15 +88,7 @@ export default function Home() {
|
||||
<div className="w-full md:w-3/4">
|
||||
<RecipeSearchBar redirect={true} favorites={false} searchOnLoad={false} setRecipes={null} />
|
||||
</div>
|
||||
<p className="leading-relaxed text-gray-800">
|
||||
Not sure what you want? {" "}
|
||||
<button onClick={viewAllRecipesHandler} className="text-blue-500 underline hover:text-blue-700 duration-300 cursor-pointer">
|
||||
View all recipes here
|
||||
</button>
|
||||
|
||||
<a href={ROUTE_CONSTANTS.Search} className="text-blue-500 underline hover:text-blue-700 duration-300">
|
||||
</a>
|
||||
</p>
|
||||
<div className="hidden" id="result-list"></div>
|
||||
</section>
|
||||
|
||||
{/* Highlight Section */}
|
||||
|
||||
@ -115,8 +115,8 @@ export default function Profile() {
|
||||
<p className="text-xs md:text-sm">{user?.Email}</p>
|
||||
</div>
|
||||
<div className="flex gap-x-4">
|
||||
<p className="text-xs md:text-sm"><span className="font-bold">{recipes?.length ?? 0}</span> recipes</p>
|
||||
<p className="text-xs md:text-sm"><span className="font-bold">{favorites?.length ?? 0}</span> favorites</p>
|
||||
<p className="text-xs md:text-sm"><span className="font-bold">{recipes.length}</span> recipes</p>
|
||||
<p className="text-xs md:text-sm"><span className="font-bold">{favorites.length}</span> favorites</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -126,10 +126,10 @@ export default function Profile() {
|
||||
<section className="p-8">
|
||||
<h2 className="text-2xl font-semibold text-gray-800">My Recipes</h2>
|
||||
<ul className="w-full my-2">
|
||||
{recipes?.length <= 4 ? (
|
||||
recipes?.map(recipe => <RecipeListItem key={recipe.Id} recipe={recipe} />)
|
||||
{recipes.length <= 4 ? (
|
||||
recipes.map(recipe => <RecipeListItem key={recipe.Id} recipe={recipe} />)
|
||||
) : (
|
||||
recipes?.slice(0, 4).map(recipe => <RecipeListItem key={recipe.Id} recipe={recipe} />)
|
||||
recipes.slice(0, 4).map(recipe => <RecipeListItem key={recipe.Id} recipe={recipe} />)
|
||||
)}
|
||||
<button onClick={seeAllRecipesHandler} className="w-full">
|
||||
<li className="w-full border-b border-gray-300 px-2 py-4 even:bg-gray-50 hover:bg-gray-100 hover:text-blue-600 duration-150 text-center">
|
||||
@ -143,10 +143,10 @@ export default function Profile() {
|
||||
<section className="p-8">
|
||||
<h2 className="text-2xl font-semibold text-gray-800">My Favorites</h2>
|
||||
<ul className="w-full my-2">
|
||||
{favorites?.length <= 4 ? (
|
||||
favorites?.map(recipe => <RecipeListItem key={recipe.Id} recipe={recipe} />)
|
||||
{favorites.length <= 4 ? (
|
||||
favorites.map(recipe => <RecipeListItem key={recipe.Id} recipe={recipe} />)
|
||||
) : (
|
||||
favorites?.slice(0, 4).map(recipe => <RecipeListItem key={recipe.Id} recipe={recipe} />)
|
||||
favorites.slice(0, 4).map(recipe => <RecipeListItem key={recipe.Id} recipe={recipe} />)
|
||||
)}
|
||||
<button onClick={seeAllFavoritesHandler} className="w-full">
|
||||
<li className="w-full border-b border-gray-300 px-2 py-4 even:bg-gray-50 hover:bg-gray-100 hover:text-blue-600 duration-150 text-center">
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { isApiError, type ApiError } from "../types/api/error";
|
||||
import { DeleteRecipe, GetRecipe, IsRecipeOwner } from "../services/RecipeService";
|
||||
import { GetRecipe } from "../services/RecipeService";
|
||||
import type { Recipe } from "../types/recipe";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useParams } from "react-router-dom";
|
||||
|
||||
import RecipePlaceholder from "../assets/images/recipe_placeholder_wide.jpg"
|
||||
import RecipeMetaData from "../components/display/RecipeMetaData";
|
||||
@ -15,10 +15,6 @@ import InstructionList from "../components/items/InstructionList";
|
||||
import Spinner from "../components/Spinner";
|
||||
import { GetUser } from "../services/UserService";
|
||||
import type { User } from "../types/user";
|
||||
import DeleteButton from "../components/buttons/DeleteButton";
|
||||
import ROUTE_CONSTANTS from "../types/routes";
|
||||
import ConfirmRecipeDeleteModal from "../components/modals/ConfirmRecipeDeleteModal";
|
||||
import EditButton from "../components/buttons/EditButton";
|
||||
|
||||
export default function RecipePage() {
|
||||
// Url params
|
||||
@ -29,77 +25,31 @@ export default function RecipePage() {
|
||||
const [author, setAuthor] = useState<User | null>(null);
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
const [isAuthor, setIsAuthor] = useState<boolean>(false);
|
||||
const [isDeleting, setIsDeleting] = useState<boolean>(false);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Functions
|
||||
const getRecipe = async (id: number) => {
|
||||
const result: Recipe | ApiError = await GetRecipe(id);
|
||||
if (isApiError(result)) {
|
||||
setError(result.message);
|
||||
} else {
|
||||
setRecipe(result);
|
||||
}
|
||||
}
|
||||
|
||||
const getAuthor = async (id: number) => {
|
||||
const result: User | ApiError = await GetUser(id);
|
||||
if (isApiError(result)) {
|
||||
setError(result.message);
|
||||
} else {
|
||||
setAuthor(result);
|
||||
}
|
||||
}
|
||||
|
||||
const getIsAuthor = async () => {
|
||||
if (!recipe) return;
|
||||
const response = await IsRecipeOwner(recipe.Id);
|
||||
if (isApiError(response)) {
|
||||
setError(response.message);
|
||||
return;
|
||||
}
|
||||
setIsAuthor(response);
|
||||
}
|
||||
|
||||
const deleteRecipe = async (id: number) => {
|
||||
const error = await DeleteRecipe(id);
|
||||
if (isApiError(error)) {
|
||||
setError(error.message)
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: Some toast, maybe?
|
||||
|
||||
await navigate(ROUTE_CONSTANTS.Home);
|
||||
}
|
||||
|
||||
// Handlers
|
||||
const deleteHandler = () => {
|
||||
if (!recipe || !isAuthor) return;
|
||||
setIsDeleting(true);
|
||||
}
|
||||
|
||||
const editHandler = () => {
|
||||
if (!recipe || !isAuthor) return;
|
||||
const route = ROUTE_CONSTANTS.Edit(Number(recipe.Id));
|
||||
void navigate(route);
|
||||
}
|
||||
|
||||
// Effects
|
||||
useEffect(() => {
|
||||
void getRecipe(Number(id));
|
||||
async function fetch() {
|
||||
const result: Recipe | ApiError = await GetRecipe(Number(id));
|
||||
if (isApiError(result)) {
|
||||
setError(result.message);
|
||||
} else {
|
||||
setRecipe(result);
|
||||
}
|
||||
}
|
||||
void fetch();
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (recipe)
|
||||
void getAuthor(recipe.UserId);
|
||||
}, [recipe]);
|
||||
async function fetch() {
|
||||
if (!recipe) return;
|
||||
|
||||
useEffect(() => {
|
||||
void getIsAuthor();
|
||||
}, [recipe, author]);
|
||||
const result: User | ApiError = await GetUser(recipe.UserId);
|
||||
if (isApiError(result)) {
|
||||
setError(result.message);
|
||||
} else {
|
||||
setAuthor(result);
|
||||
}
|
||||
}
|
||||
void fetch();
|
||||
}, [recipe]);
|
||||
|
||||
// BUG: Prob remove
|
||||
useEffect(() => {
|
||||
@ -109,12 +59,6 @@ export default function RecipePage() {
|
||||
|
||||
return recipe ? (
|
||||
<>
|
||||
{isDeleting &&
|
||||
<ConfirmRecipeDeleteModal
|
||||
cancelHandler={() => setIsDeleting(false)}
|
||||
deleteHandler={() => void deleteRecipe(recipe.Id)}
|
||||
/>}
|
||||
|
||||
<img className="bg-gray-100 w-full h-64 md:h-96 mx-auto mb-8" src={RecipePlaceholder} />
|
||||
<div className="px-4 py-8 md:px-8">
|
||||
<h1 className="text-3xl md:text-4xl font-bold text-gray-800">{recipe.Title}</h1>
|
||||
@ -122,21 +66,14 @@ export default function RecipePage() {
|
||||
<p className="text-sm mb-2 text-gray-700">Category: {recipe.Category}</p>
|
||||
</div>
|
||||
<RecipeMetaData recipe={recipe} />
|
||||
<section className="w-full flex flex-col md:flex-row gap-x-4 gap-y-2 py-8 px-4 md:px-8 flex-wrap">
|
||||
<section className="w-full flex flex-col md:flex-row gap-x-4 gap-y-2 py-8 px-4 md:px-8">
|
||||
<FavoriteButton favorite={recipe.Favorite} id={recipe.Id} />
|
||||
<MadeButton id={recipe.Id} />
|
||||
<ShareButton id={recipe.Id} />
|
||||
|
||||
{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>
|
||||
<p className="text-gray-700 whitespace-pre-line wrap-break-word">{recipe.Description}</p>
|
||||
<p className="text-gray-700">{recipe.Description}</p>
|
||||
</div>
|
||||
<IngredientList sections={recipe.Sections} ingredients={recipe.Ingredients} />
|
||||
<InstructionList instructions={recipe.Instructions} />
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import axios from "axios";
|
||||
import type { CreateRecipeRequest, CreateRecipeResponse, DeleteRecipeResponse, EditRecipeRequest, GetRecipeOfTheWeekResponse, GetRecipeResponse, IsRecipeOwnerResponse, SearchRecipesResponse, EditRecipeResponse } from "../types/api/recipe";
|
||||
import type { CreateRecipeRequest, CreateRecipeResponse, GetRecipeOfTheWeekResponse, GetRecipeResponse, SearchRecipesResponse } from "../types/api/recipe";
|
||||
import type { Recipe } from "../types/recipe";
|
||||
import type { ApiError } from "../types/api/error";
|
||||
import type { SearchFilters } from "../types/search";
|
||||
@ -63,45 +63,3 @@ export async function CreateRecipe(data: CreateRecipeRequest): Promise<Recipe |
|
||||
|
||||
return response.data.recipe;
|
||||
}
|
||||
|
||||
export async function EditRecipe(data: EditRecipeRequest): Promise<Recipe | ApiError> {
|
||||
const response = await axios.put<EditRecipeResponse>(`${BACKEND_URL}/v2/api/recipe/${data.Id}`, data);
|
||||
|
||||
if (response.status !== 200 || response.data.recipe === undefined) {
|
||||
const err: ApiError = {
|
||||
status: response.data.status,
|
||||
message: response.data.message
|
||||
};
|
||||
return err;
|
||||
}
|
||||
|
||||
return response.data.recipe;
|
||||
}
|
||||
|
||||
export async function DeleteRecipe(id: number): Promise<ApiError | null> {
|
||||
const response = await axios.delete<DeleteRecipeResponse>(`${BACKEND_URL}/v2/api/recipe/${id}`);
|
||||
|
||||
if (response.status !== 200) {
|
||||
const err: ApiError = {
|
||||
status: response.data.status,
|
||||
message: response.data.message
|
||||
};
|
||||
return err;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function IsRecipeOwner(recipeId: number): Promise<boolean | ApiError> {
|
||||
const response = await axios.get<IsRecipeOwnerResponse>(`${BACKEND_URL}/v2/api/recipe/${recipeId}/is-owner`);
|
||||
|
||||
if (response.status !== 200) {
|
||||
const err: ApiError = {
|
||||
status: response.data.status,
|
||||
message: response.data.message
|
||||
};
|
||||
return err;
|
||||
}
|
||||
|
||||
return response.data.owner;
|
||||
}
|
||||
|
||||
@ -24,12 +24,6 @@ export interface CreateRecipeResponse {
|
||||
recipe?: Recipe;
|
||||
}
|
||||
|
||||
export interface EditRecipeResponse {
|
||||
status: number;
|
||||
message: string;
|
||||
recipe?: Recipe;
|
||||
}
|
||||
|
||||
export interface CreateRecipeRequest {
|
||||
Title: string;
|
||||
Description: string;
|
||||
@ -42,28 +36,3 @@ export interface CreateRecipeRequest {
|
||||
Sections: RecipeIngredientSection[];
|
||||
Tags: string[];
|
||||
}
|
||||
|
||||
export interface EditRecipeRequest {
|
||||
Id: number;
|
||||
Title: string;
|
||||
Description: string;
|
||||
Instructions: RecipeInstruction[];
|
||||
Serves: number;
|
||||
Difficulty: number;
|
||||
Duration: RecipeDuration;
|
||||
Category: RecipeMeal;
|
||||
Ingredients: RecipeIngredient[];
|
||||
Sections: RecipeIngredientSection[];
|
||||
Tags: string[];
|
||||
}
|
||||
|
||||
export interface DeleteRecipeResponse {
|
||||
status: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface IsRecipeOwnerResponse {
|
||||
owner: boolean;
|
||||
status: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
|
||||
export type EngagementType = "made" | "liked" | "viewed" | "shared" | "reviewed" | "rated" | "created" | "deleted" | "edited";
|
||||
export type EngagementType = "made" | "liked" | "viewed" | "shared" | "reviewed" | "rated";
|
||||
|
||||
export interface Engagement {
|
||||
Id: number;
|
||||
|
||||
4
web/src/types/filters.ts
Normal file
4
web/src/types/filters.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export interface SearchFilters {
|
||||
|
||||
|
||||
}
|
||||
0
web/src/types/index.ts
Normal file
0
web/src/types/index.ts
Normal file
@ -93,5 +93,4 @@ export interface Recipe {
|
||||
Created: Date;
|
||||
Tags: Tag[];
|
||||
Favorite: boolean;
|
||||
Deleted: boolean;
|
||||
}
|
||||
|
||||
@ -8,9 +8,7 @@ const ROUTE_CONSTANTS: {
|
||||
ShoppingList: string;
|
||||
Login: string;
|
||||
History: string;
|
||||
Search: string;
|
||||
Recipe: (id: number) => string;
|
||||
Edit: (id: number) => string;
|
||||
} = {
|
||||
Home: `${VERSION_FLAG}/web/home`,
|
||||
Favorites: `${VERSION_FLAG}/web/favorites`,
|
||||
@ -19,9 +17,7 @@ const ROUTE_CONSTANTS: {
|
||||
ShoppingList: `${VERSION_FLAG}/web/list`,
|
||||
Login: `${VERSION_FLAG}/web/login`,
|
||||
History: `${VERSION_FLAG}/web/history`,
|
||||
Search: `${VERSION_FLAG}/web/search`,
|
||||
Recipe: (id: number) => `${VERSION_FLAG}/web/recipe/${id}`,
|
||||
Edit: (id: number) => `${VERSION_FLAG}/web/create?edit=${id}`,
|
||||
};
|
||||
|
||||
export default ROUTE_CONSTANTS;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user