Compare commits

...

55 Commits

Author SHA1 Message Date
4236e44df4 Merge pull request 'fix: reset the filters when the "search all" is hit' (#97) from fix/filter-clear into master
All checks were successful
Deploy application with Docker / build_and_deploy (push) Successful in 2m44s
Reviewed-on: #97
2026-03-23 22:19:37 -07:00
Hayden Hargreaves
9c2c7f976f fix: reset the filters when the "search all" is hit 2026-03-23 22:19:03 -07:00
3662ced22b Merge pull request 'fix: fixed domain in UI settings of cookie' (#96) from dev into master
All checks were successful
Deploy application with Docker / build_and_deploy (push) Successful in 1m2s
Reviewed-on: #96
2026-03-12 20:24:09 -07:00
4645d61d65 Merge branch 'master' into dev 2026-03-12 20:24:05 -07:00
Hayden Hargreaves
cb6ac7610c Merge branch 'dev' of gitea:azpect/Potion into dev 2026-03-12 20:23:42 -07:00
Hayden Hargreaves
57ffa49c5b fix: fixed domain in UI settings of cookie 2026-03-12 20:23:22 -07:00
787fff6bb0 Merge pull request 'dev' (#95) from dev into master
All checks were successful
Deploy application with Docker / build_and_deploy (push) Successful in 53s
Reviewed-on: #95
2026-03-12 20:18:06 -07:00
34080466bd Merge branch 'master' into dev 2026-03-12 20:17:40 -07:00
Hayden Hargreaves
23d426ad71 Merge branch 'dev' of gitea:azpect/Potion into dev 2026-03-12 20:17:01 -07:00
Hayden Hargreaves
bacb070e6d fix: Using UI to set the cookie instead of server. 2026-03-12 20:16:45 -07:00
acb1ed1fd3 Merge pull request 'Trying to fix cookies.' (#94) from dev into master
All checks were successful
Deploy application with Docker / build_and_deploy (push) Successful in 3m45s
Reviewed-on: #94
2026-03-12 19:39:03 -07:00
8aa748d7ed Merge branch 'master' into dev 2026-03-12 19:38:56 -07:00
Hayden Hargreaves
3ad2c93448 Merge branch 'dev' of gitea:azpect/Potion into dev 2026-03-12 19:37:28 -07:00
Hayden Hargreaves
efeaccc6e3 fix: trying this on prod 2026-03-12 19:37:15 -07:00
f798ddb74c Merge pull request 'feature/orm' (#92) from feature/orm into master
All checks were successful
Deploy application with Docker / build_and_deploy (push) Successful in 57s
Reviewed-on: #92
2026-02-07 23:44:42 -07:00
413510d2cd Merge branch 'master' into feature/orm 2026-02-07 23:44:37 -07:00
Hayden Hargreaves
a1d6c22b22 Merge branch 'feature/orm' of gitea:azpect/Potion into feature/orm 2026-02-07 23:44:21 -07:00
Hayden Hargreaves
9f0a9d2d85 (FIX): Failed to build UI, here a fix. 2026-02-07 23:43:55 -07:00
eb2ebff0ae Merge pull request '(FIX): Some ORM things and an attempt to fix CI/CD' (#91) from feature/orm into master
Some checks failed
Deploy application with Docker / build_and_deploy (push) Failing after 57s
Reviewed-on: #91
2026-02-07 23:42:03 -07:00
a98cfcfe95 Merge branch 'master' into feature/orm 2026-02-07 23:41:58 -07:00
Hayden Hargreaves
eea1f09769 (FIX): Some ORM things and an attempt to fix CI/CD 2026-02-07 23:41:31 -07:00
217a9bf416 Merge pull request 'Need to merge editing in: Not sure why it isnt working' (#90) from feature/orm into master
Some checks failed
Deploy application with Docker / build_and_deploy (push) Failing after 19s
Reviewed-on: #90
2026-02-07 11:17:51 -07:00
Hayden Hargreaves
6e28ebfe80 (FEAT): Orm work, not complete, but editing needs to be merged. 2026-02-07 11:17:00 -07:00
Hayden Hargreaves
e81f2ec513 (FIX): Removed debug logs and such. 2026-02-03 23:02:07 -07:00
Hayden Hargreaves
9398e06943 (FIX): Removed legacy code from search API. 2026-02-03 22:58:07 -07:00
Hayden Hargreaves
4bd4c4ca26 (FEAT): First squirrel implementation. Search! 2026-02-03 22:56:26 -07:00
Hayden Hargreaves
5037b0c2db (FEAT): SQLX migration. 2026-02-03 22:33:55 -07:00
d1ecd8f5a3 Merge pull request '(FIX): Fixed some of the search.' (#89) from fix/search into master
Some checks failed
Deploy application with Docker / build_and_deploy (push) Failing after 43s
Reviewed-on: #89
2026-02-03 14:28:50 -07:00
Hayden Hargreaves
8e53ef9cf6 (FIX): Fixed some of the search.
Still an ORM sounds fun!
2026-02-03 14:27:54 -07:00
1e88f075cb Merge pull request '(FEAT): Recipe editing is complete!' (#87) from feature/edit-recipe into master
Some checks failed
Deploy application with Docker / build_and_deploy (push) Failing after 43s
Reviewed-on: #87
2026-02-01 00:30:45 -07:00
Hayden Hargreaves
7951945c3b (FEAT): Recipe editing is complete!
This will be deployed for testing!
2026-02-01 00:30:13 -07:00
a501ddc82e Merge pull request '(FIX): Fixed profile display issue.' (#85) from dev into master
All checks were successful
Deploy application with Docker / build_and_deploy (push) Successful in 55s
Reviewed-on: #85
2026-01-31 10:19:01 -07:00
Hayden Hargreaves
5179707d0f (FIX): Fixed profile display issue. 2026-01-31 10:18:36 -07:00
62f56e5efd Merge pull request '(FEAT): Deletion is implemented!' (#84) from feature/delete-recipes into master
All checks were successful
Deploy application with Docker / build_and_deploy (push) Successful in 1m11s
Reviewed-on: #84
2026-01-30 23:45:26 -07:00
2efff87a23 Merge branch 'master' into feature/delete-recipes 2026-01-30 23:45:18 -07:00
Hayden Hargreaves
b8c9fc469e (FEAT): Deletion is implemented!
This should be a nice test! Hopefully other users cannot delete my
recipes. Though they're setup to soft delete.
2026-01-30 23:44:21 -07:00
6b2b17b0ba Merge pull request '(FIX): Needed some work on the logging and handlers.' (#81) from dev into master
All checks were successful
Deploy application with Docker / build_and_deploy (push) Successful in 1m3s
Reviewed-on: #81
2026-01-27 12:37:58 -07:00
Hayden Hargreaves
396ca08381 (FIX): Needed some work on the logging and handlers. 2026-01-27 12:36:30 -07:00
a78a79bfab Merge pull request 'Merging dev into master' (#80) from dev into master
All checks were successful
Deploy application with Docker / build_and_deploy (push) Successful in 1m7s
Reviewed-on: #80
2026-01-26 23:34:00 -07:00
41b8bb6344 Merge pull request '(FIX): Fixed up the environent, much needed.' (#79) from feature/env-fix into dev
Reviewed-on: #79
2026-01-26 23:27:08 -07:00
Hayden Hargreaves
a54575b003 (FIX): Fixed up the environent, much needed.
Super cool package that uses struct tags to load the environment. Still
need to clean up the function in the server/server.go file.
2026-01-26 23:26:07 -07:00
bd961beb3a Merge pull request 'MERGING LOGGER INTO DEV' (#78) from feature/logger into dev
Reviewed-on: #78
2026-01-26 22:44:12 -07:00
Hayden Hargreaves
dd43845138 (FEAT): Logger is implemented!
However, its not used everywhere and the ENV needs work. Lots of work...
2026-01-26 22:41:40 -07:00
Hayden Hargreaves
aca3c8b4ee (FIX): Logging will be fully implemented later
For now, I want to implement a DB logger.
2026-01-23 10:29:08 -07:00
Hayden Hargreaves
72c9cb0f96 (FEAT): Logging is so much better now :) 2026-01-23 10:25:51 -07:00
Hayden Hargreaves
745a59ecaa (WIP): Working on the loggers 2026-01-15 13:10:53 -07:00
194c738d07 Merge pull request '(FEAT): Allow larger serving sizes.' (#77) from dev into master
All checks were successful
Deploy application with Docker / build_and_deploy (push) Successful in 59s
Reviewed-on: #77
2026-01-13 12:29:55 -07:00
Hayden Hargreaves
8f3cdefe7e (FEAT): Allow larger serving sizes.
Now we can range between 1 and 127.
2026-01-13 12:28:56 -07:00
9d06e979b8 Merge pull request '(FEAT): Implemented creation engagement.' (#76) from feature/create-engagement into master
All checks were successful
Deploy application with Docker / build_and_deploy (push) Successful in 44s
Reviewed-on: #76
2026-01-10 12:44:30 -07:00
Hayden Hargreaves
8ae705db89 (FEAT): Implemented creation engagement. 2026-01-10 12:44:04 -07:00
e0d423f806 Merge pull request '(FEAT): Added the view all button for Connor!' (#74) from feature/view-all into master
All checks were successful
Deploy application with Docker / build_and_deploy (push) Successful in 58s
Reviewed-on: #74
2026-01-10 12:15:37 -07:00
Hayden Hargreaves
2bd894ad9a (FEAT): Added the view all button for Connor! 2026-01-10 12:15:10 -07:00
439f7d219e Merge pull request '(FIX): Fixed the description display issue.' (#72) from fix/description into master
All checks were successful
Deploy application with Docker / build_and_deploy (push) Successful in 58s
Reviewed-on: #72
2026-01-09 19:35:43 -07:00
Hayden Hargreaves
5e850d8127 (FIX): Fixed the description display issue. 2026-01-09 19:34:55 -07:00
06a7f22182 Merge pull request '(FEAT): New favicon' (#71) from refactor/react into master
All checks were successful
Deploy application with Docker / build_and_deploy (push) Successful in 57s
Reviewed-on: #71
2026-01-09 18:30:52 -07:00
65 changed files with 1826 additions and 585 deletions

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
/flake.lock /flake.lock
/.env /.env
/*.dump /*.dump
/*.log

View File

@ -2,6 +2,9 @@ FROM golang:1.25-alpine
WORKDIR /app WORKDIR /app
# Solution t IP block?
ENV GOPROXY=https://goproxy.io,https://athens.azurefd.net,direct
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download

View File

@ -1,12 +1,12 @@
package main package main
import "github.com/haydenhargreaves/Potion/internal/app/server" import (
"github.com/haydenhargreaves/Potion/internal/app/server"
)
const PORT = 3000 const PORT = 3000
func main() { func main() {
s := server.Init(PORT).Setup() s := server.Init(PORT).Setup()
defer s.DB.Close()
s.Start() s.Start()
} }

6
go.mod
View File

@ -15,6 +15,7 @@ require (
require ( require (
cloud.google.com/go/compute/metadata v0.3.0 // indirect 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 v1.13.2 // indirect
github.com/bytedance/sonic/loader v0.2.4 // indirect github.com/bytedance/sonic/loader v0.2.4 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect github.com/cloudwego/base64x v0.1.5 // indirect
@ -24,9 +25,14 @@ require (
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.26.0 // indirect github.com/go-playground/validator/v10 v10.26.0 // indirect
github.com/goccy/go-json v0.10.5 // 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/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/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/kr/text v0.2.0 // 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/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect

16
go.sum
View File

@ -1,5 +1,8 @@
cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc= cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= 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 h1:g9oxL/dmM6tvwRe2egJS8hBDQTncokbMoOFk1oJMX7s=
github.com/a-h/templ v0.3.898/go.mod h1:oLBbZVQ6//Q6zpvSMPTuBK0F3qOtBdFBcGRspcT+VNQ= 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= github.com/a-h/templ v0.3.920 h1:IQjjTu4KGrYreHo/ewzSeS8uefecisPayIIc9VflLSE=
@ -48,6 +51,7 @@ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBEx
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/go-playground/validator/v10 v10.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 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= 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 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= 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= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
@ -57,16 +61,22 @@ 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 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 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/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 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o=
github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM= 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 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= 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 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= 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 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 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 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 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.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 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
@ -74,12 +84,17 @@ github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/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/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/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 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= 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 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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-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 h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@ -95,6 +110,7 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.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.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 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.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.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=

View File

@ -4,7 +4,6 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"net/url" "net/url"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@ -32,12 +31,13 @@ func (s *Server) GoogleCallbackHandlerV2(ctx *gin.Context) {
domain := s.deps.EnvironmentConfig.FrontendDomain domain := s.deps.EnvironmentConfig.FrontendDomain
if jwt, err := s.deps.AuthService.GoogleAuthSuccess(state, code); err != nil { if jwt, err := s.deps.AuthService.GoogleAuthSuccess(state, code); err != nil {
url := fmt.Sprintf("%s/v2/web/login?error=%s", domain, url.QueryEscape(err.Error())) redirectUrl := fmt.Sprintf("%s/v2/web/login?error=%s", domain, url.QueryEscape(err.Error()))
ctx.Redirect(http.StatusSeeOther, url) ctx.Redirect(http.StatusSeeOther, redirectUrl)
} else { } else {
url := fmt.Sprintf("%s/v2/web/home", domain) // Pass JWT via query param - frontend will set the cookie
s.SetCookie(ctx, "jwt_token", jwt, time.Hour*24*7) // This bypasses cross-origin cookie issues with Cloudflare/proxies
ctx.Redirect(http.StatusSeeOther, url) redirectUrl := fmt.Sprintf("%s/v2/web/auth/callback?token=%s", domain, url.QueryEscape(jwt))
ctx.Redirect(http.StatusSeeOther, redirectUrl)
} }
} }

View File

@ -43,8 +43,8 @@ func (s *Server) SetCookie(ctx *gin.Context, name, value string, duration time.D
value, value,
maxAge, maxAge,
path, path,
".gophernest.net", // or your backend domain / parent "gophernest.net",
true, // secure true,
httpOnly, httpOnly,
) )
case "dev": case "dev":

View File

@ -2,13 +2,13 @@ package server
import ( import (
"fmt" "fmt"
"log"
"net/http" "net/http"
"runtime/debug" "runtime/debug"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
domain "github.com/haydenhargreaves/Potion/internal/domain/server" 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 // DepedencyInjectionMiddleware injects the dependencies into the context set. This is a middleware
@ -77,14 +77,13 @@ func JwtAuthMiddleWare(jwtSecretKey []byte) gin.HandlerFunc {
} }
} }
func RecoveryMiddleware() gin.HandlerFunc { func RecoveryMiddleware(logs []logging.Logger) gin.HandlerFunc {
return func(ctx *gin.Context) { return func(ctx *gin.Context) {
defer func() { defer func() {
if r := recover(); r != nil { if r := recover(); r != nil {
// Log the panic with stack trace // Log the panic with stack trace
err := fmt.Errorf("panic recovered: %v\n%s", r, debug.Stack()) err := fmt.Errorf("panic recovered: %v\n%s", r, debug.Stack())
log.Printf("[PANIC RECOVERY] %s", err) logging.LogAll(logs, logging.LogLevelFatal, "[PANIC RECOVERY] %s\n", err)
ctx.JSON(http.StatusOK, gin.H{ ctx.JSON(http.StatusOK, gin.H{
"status": http.StatusOK, "status": http.StatusOK,

View File

@ -3,10 +3,12 @@ package server
import ( import (
"fmt" "fmt"
"net/http" "net/http"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
domain "github.com/haydenhargreaves/Potion/internal/domain/server" 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 // JwtAuthMiddlewareV2 is responsible for protecting routes. Anything that may go wrong
@ -96,3 +98,34 @@ func JwtOptionalAuthMiddlewareV2(jwtSecretKey []byte) gin.HandlerFunc {
ctx.Next() 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,
)
}
}

View File

@ -7,6 +7,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
domain "github.com/haydenhargreaves/Potion/internal/domain/recipe" 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. // GetRecipeOfTheWeekHandler fetchs the current recipe of the week and returns it.
@ -96,6 +97,15 @@ func (s *Server) SearchRecipeHandlerV2(ctx *gin.Context) {
} }
func (s *Server) CreateRecipeHandlerV2(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) recipe, err := s.deps.RecipeService.CreateRecipe(ctx)
if err != nil { if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{ ctx.JSON(http.StatusBadRequest, gin.H{
@ -105,9 +115,116 @@ func (s *Server) CreateRecipeHandlerV2(ctx *gin.Context) {
return 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{ ctx.JSON(http.StatusOK, gin.H{
"status": http.StatusOK, "status": http.StatusOK,
"message": "[OK] Successfully created new recipe.", "message": "[OK] Successfully created new recipe.",
"recipe": 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.",
})
}

View File

@ -1,10 +1,12 @@
package server package server
import ( import (
"database/sql"
"fmt" "fmt"
"net/http" "net/http"
"os"
"os/signal"
"strings" "strings"
"syscall"
"github.com/a-h/templ/examples/integration-gin/gintemplrenderer" "github.com/a-h/templ/examples/integration-gin/gintemplrenderer"
"github.com/gin-contrib/cors" "github.com/gin-contrib/cors"
@ -13,6 +15,9 @@ import (
domain "github.com/haydenhargreaves/Potion/internal/domain/server" domain "github.com/haydenhargreaves/Potion/internal/domain/server"
"github.com/haydenhargreaves/Potion/internal/infrastructure/auth" "github.com/haydenhargreaves/Potion/internal/infrastructure/auth"
"github.com/haydenhargreaves/Potion/internal/infrastructure/database/repository" "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" _ "github.com/lib/pq"
) )
@ -21,20 +26,28 @@ type Server struct {
port int port int
Router *gin.Engine Router *gin.Engine
config cors.Config config cors.Config
DB *sql.DB DB *sqlx.DB
deps domain.InjectedDependencies deps domain.InjectedDependencies
logs []logging.Logger
cleanupFuncs []func() error
} }
// Init initializes the server with the provided port. CORS settings are defined here. // 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. // A pointer to a server object is returned which allows for method chaining.
func Init(port int) *Server { func Init(port int) *Server {
server := &Server{ server := &Server{
Router: gin.Default(), Router: gin.New(), // Not default anymore, to allow for custom logger
port: port, port: port,
config: cors.DefaultConfig(), config: cors.DefaultConfig(),
logs: []logging.Logger{},
cleanupFuncs: []func() error{},
} }
// Default logger which logs everything
server.logs = append(server.logs, loggers.NewConsoleLogger(logging.LogLevelTrace))
// Some stuff for templ rendering // Some stuff for templ rendering
// TODO: Remove this
htmlRenderer := server.Router.HTMLRender htmlRenderer := server.Router.HTMLRender
server.Router.HTMLRender = &gintemplrenderer.HTMLTemplRenderer{FallbackHtmlRenderer: htmlRenderer} server.Router.HTMLRender = &gintemplrenderer.HTMLTemplRenderer{FallbackHtmlRenderer: htmlRenderer}
@ -49,32 +62,59 @@ func Init(port int) *Server {
server.config.AllowCredentials = true server.config.AllowCredentials = true
server.Router.Use(cors.New(server.config)) 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 return server
} }
// Start starts the server on the port provided when the server was initialized // Start starts the server on the port provided when the server was initialized
func (s *Server) Start() { func (s *Server) Start() {
s.Router.Run(fmt.Sprintf(":%d", s.port)) 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")
}
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: (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
func (s *Server) Setup() *Server { func (s *Server) Setup() *Server {
// SETUP THE ENVIRONMENT CONFIGURATION // SETUP THE ENVIRONMENT CONFIGURATION
cfg, err := domain.LoadEnvironment() cfg, err := domain.LoadEnvironment()
if err != nil { if err != nil {
logging.LogAll(s.logs, logging.LogLevelFatal, err.Error())
panic(err.Error()) panic(err.Error())
} }
if cfg == nil { if cfg == nil {
logging.LogAll(s.logs, logging.LogLevelFatal, "Environment configuration is nil, crashing.")
panic("Environment configuration is nil, crashing.") panic("Environment configuration is nil, crashing.")
} }
if cfg.Environment == "dev" { logging.LogAll(s.logs, logging.LogLevelDebug, "env: %+v\n", cfg)
gin.SetMode(gin.DebugMode)
} else if cfg.Environment == "prod" {
gin.SetMode(gin.ReleaseMode)
} else {
gin.SetMode(gin.TestMode)
}
// SETUP GOOGLE AUTH // SETUP GOOGLE AUTH
var ( var (
@ -92,7 +132,7 @@ func (s *Server) Setup() *Server {
auth.NewGoogleConfig(redirectUrl, clientId, clientSecret, scope) auth.NewGoogleConfig(redirectUrl, clientId, clientSecret, scope)
// SETUP DATABASE // SETUP DATABASE
db, err := sql.Open("postgres", cfg.DatabaseUrl) db, err := sqlx.Open("postgres", cfg.DatabaseUrl)
if err != nil { if err != nil {
panic("Could not connect to database: " + err.Error()) panic("Could not connect to database: " + err.Error())
} }
@ -103,6 +143,26 @@ func (s *Server) Setup() *Server {
s.DB = db 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 // SETUP JWT
jwtSecret := []byte(cfg.JwtSecret) jwtSecret := []byte(cfg.JwtSecret)
@ -111,7 +171,7 @@ func (s *Server) Setup() *Server {
recipeRepo := repository.NewRecipeRepository(s.DB) recipeRepo := repository.NewRecipeRepository(s.DB)
engagementRepo := repository.NewEngagementRepository(s.DB) engagementRepo := repository.NewEngagementRepository(s.DB)
userService := service.NewUserService(userRepo) userService := service.NewUserService(userRepo)
authService := service.NewAuthService(userRepo, jwtSecret) authService := service.NewAuthService(userRepo, jwtSecret, s.logs)
recipeService := service.NewRecipeService(recipeRepo, engagementRepo) recipeService := service.NewRecipeService(recipeRepo, engagementRepo)
engagementService := service.NewEngagementService(engagementRepo, recipeRepo) engagementService := service.NewEngagementService(engagementRepo, recipeRepo)
@ -124,9 +184,7 @@ func (s *Server) Setup() *Server {
} }
// Apply middleware // Apply middleware
s.Router.Use(RecoveryMiddleware()) s.Router.Use(gin.Recovery(), RecoveryMiddleware(s.logs), LoggingMiddleware(s.logs))
// NOTE: No longer running on every connection!
// s.Router.Use(JwtAuthMiddleWare(jwtSecret))
// Redirect index to home page: Update this as needed // Redirect index to home page: Update this as needed
s.Router.GET("/", func(ctx *gin.Context) { ctx.Redirect(http.StatusSeeOther, domain.WEB_HOME) }) s.Router.GET("/", func(ctx *gin.Context) { ctx.Redirect(http.StatusSeeOther, domain.WEB_HOME) })
@ -201,10 +259,13 @@ func (s *Server) Setup() *Server {
// ---- VERSION 2 ROUTES ---- // // ---- VERSION 2 ROUTES ---- //
router_api_v2 := router_v2.Group(domain.API) 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.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.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.POST("/recipe/search", JwtOptionalAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.SearchRecipeHandlerV2)
router_api_v2.POST("/recipe", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.CreateRecipeHandlerV2) 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.GET("/auth/login", s.GetGoogleAuthUrlHandlerV2) router_api_v2.GET("/auth/login", s.GetGoogleAuthUrlHandlerV2)
router_api_v2.GET("/auth/callback", s.GoogleCallbackHandlerV2) router_api_v2.GET("/auth/callback", s.GoogleCallbackHandlerV2)
@ -219,14 +280,21 @@ func (s *Server) Setup() *Server {
router_api_v2.GET("/user/recipes/made", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.GetAuthenicatedUserMadeRecipesV2) 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("/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/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/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/favorite/:id", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.EngagementFavoriteRecipeHandlerV2)
router_api_v2.POST("/engagement/make/:id", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.EngagementMakeRecipeHandlerV2) router_api_v2.POST("/engagement/make/:id", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.EngagementMakeRecipeHandlerV2)
if cfg.Environment == "dev" {
s.debugDisplayRoutes()
}
return s 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)
}
}

View File

@ -10,6 +10,7 @@ import (
domainServer "github.com/haydenhargreaves/Potion/internal/domain/server" domainServer "github.com/haydenhargreaves/Potion/internal/domain/server"
domain "github.com/haydenhargreaves/Potion/internal/domain/user" domain "github.com/haydenhargreaves/Potion/internal/domain/user"
"github.com/haydenhargreaves/Potion/internal/infrastructure/auth" "github.com/haydenhargreaves/Potion/internal/infrastructure/auth"
"github.com/haydenhargreaves/Potion/internal/infrastructure/logging"
"golang.org/x/oauth2" "golang.org/x/oauth2"
) )
@ -31,6 +32,7 @@ import (
type AuthService struct { type AuthService struct {
userRepository domain.UserRepository userRepository domain.UserRepository
jwtSecret []byte jwtSecret []byte
logs []logging.Logger
} }
// Compile-time check to ensure the AuthService implements domain.AuthService // Compile-time check to ensure the AuthService implements domain.AuthService
@ -38,10 +40,11 @@ var _ domainAuth.AuthService = (*AuthService)(nil)
// NewAuthService creates a user service object which can be passed into the context. The service // 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. // requires a user repository which it will use to hit the database when needed.
func NewAuthService(userRepository domain.UserRepository, jwtSecret []byte) domainAuth.AuthService { func NewAuthService(userRepository domain.UserRepository, jwtSecret []byte, logs []logging.Logger) domainAuth.AuthService {
return &AuthService{ return &AuthService{
userRepository: userRepository, userRepository: userRepository,
jwtSecret: jwtSecret, jwtSecret: jwtSecret,
logs: logs,
} }
} }
@ -124,3 +127,53 @@ func generateJwt(userId int, email string, jwtSecret []byte) (string, error) {
return tokenString, nil 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
// )
// }
// }

View File

@ -122,6 +122,48 @@ func (s *EngagementService) UserShareRecipe(userId, recipeId int) (domain.Engage
return s.engagementRepository.AddUserEntityEngagement(userId, recipeId, message, domain.EngagementShared) 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 // 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. // 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) { func (s *EngagementService) GetUserEngagement(userId, limit int) ([]domain.Engagement, error) {

View File

@ -30,7 +30,7 @@ func NewRecipeService(recipeRepository domain.RecipeRepository, engagementReposi
// CreateRecipe creates a recipe in the database using the recipe repository. This function requires // 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 // 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 called when they // will enforce validation, as will the database. Errors will be returned to the caller when they
// occur. // occur.
// //
// TODO: Implement validation in the API. // TODO: Implement validation in the API.
@ -78,85 +78,60 @@ func (s *RecipeService) CreateRecipe(ctx *gin.Context) (*domain.Recipe, error) {
} }
return &recipe, nil return &recipe, nil
}
// title := ctx.PostForm("title") func (s *RecipeService) EditRecipe(ctx *gin.Context, recipeId, userId int) (*domain.Recipe, error) {
// description := ctx.PostForm("description") var req domain.EditRecipeRequest
// preparation := ctx.PostForm("preparation-time")
// cook := ctx.PostForm("cook-time") if err := ctx.ShouldBindJSON(&req); err != nil {
// serving := ctx.PostForm("serving-size") return nil, err
// category := ctx.PostForm("category") }
// difficulty := ctx.PostForm("difficulty")
// ingredients := ctx.PostFormArray("ingredients") if recipeId != req.Id {
// quantity := ctx.PostFormArray("quantity") return nil, fmt.Errorf("[ERROR] Mismatched recipe IDs provided. Given %d and %d.", recipeId, req.Id)
// instructions := ctx.PostFormArray("instructions") }
// tags := strings.Split(ctx.PostForm("tags"), ",")
// userId := ctx.MustGet("userId").(int) recipe := domain.Recipe{
// Id: recipeId,
// // Have to get the image differently Title: req.Title,
// image, err := ctx.FormFile("image") Description: req.Description,
// if err != nil && !errors.Is(err, http.ErrMissingFile) { Instructions: req.Instructions,
// // Error getting image Serves: req.Serves,
// } Difficulty: req.Difficulty,
// Duration: req.Duration,
// // Convert to proper values Category: req.Category,
// servingInt, _ := strconv.Atoi(serving) Ingredients: req.Ingredients,
// difficultyInt, _ := strconv.Atoi(difficulty) Sections: req.Sections,
// prepInt, _ := strconv.Atoi(preparation) }
// cookInt, _ := strconv.Atoi(cook)
// if err := s.recipeRepository.EditRecipe(&recipe, userId); err != nil {
// var ingredientSlice []domain.RecipeIngredient return &recipe, err
// for i := range len(ingredients) { }
// if strings.TrimSpace(ingredients[i]) != "" {
// ins := domain.RecipeIngredient{ // Update the tags
// Name: ingredients[i], if len(req.Tags) > 0 {
// Quantity: quantity[i], if err := s.recipeRepository.UpdateRecipeTags(recipe, req.Tags); err != nil {
// } return &recipe, fmt.Errorf("Failed to attach/create tags. %s\n", err.Error())
// }
// ingredientSlice = append(ingredientSlice, ins) }
// }
// } return &recipe, nil
// }
// var instructionSlice []string
// for _, ins := range instructions { // DeleteRecipe deletes a recipe in the database using the recipe repository. This function requires
// if ins != "" { // the userId of the requesting user - to ensure the user is the owner of the recipe. Any errors will
// instructionSlice = append(instructionSlice, ins) // 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 {
// // Create the recipe return fmt.Errorf("Recipe does not exist or has been relocated. Please try again. %s", err.Error())
// recipe := domain.Recipe{ }
// Title: title,
// Description: description, if recipe.UserId != userId {
// Instructions: instructionSlice, return fmt.Errorf("User id does not match. Do you own the target recipe?")
// Serves: servingInt, }
// Difficulty: difficultyInt,
// Duration: domain.RecipeDuration{ return s.recipeRepository.DeleteRecipe(recipeId, userId)
// 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, // GetRecipe will get a recipe via its ID. Any errors will be bubbled to the caller. Furthermore,
@ -271,3 +246,15 @@ func (s *RecipeService) GetRecipeOfTheWeek(userId *int) (*domain.Recipe, error)
return s.recipeRepository.GetRecipe(*id, userId) 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)
}

View File

@ -14,6 +14,9 @@ const (
EngagementShared EngagementType = "shared" EngagementShared EngagementType = "shared"
EngagementReviewed EngagementType = "reviewed" EngagementReviewed EngagementType = "reviewed"
EngagementRated EngagementType = "rated" 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 // Engagement is the database model of a user engagement. There is no need to map to a different

View File

@ -7,5 +7,8 @@ type EngagementService interface {
UserFavoriteRecipe(userId, recipeId int) (Engagement, error) UserFavoriteRecipe(userId, recipeId int) (Engagement, error)
UserMakeRecipe(userId, recipeId int) (Engagement, error) UserMakeRecipe(userId, recipeId int) (Engagement, error)
UserShareRecipe(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) GetUserEngagement(userId, limit int) ([]Engagement, error)
} }

View File

@ -124,6 +124,7 @@ type Recipe struct {
Created time.Time Created time.Time
Tags []Tag Tags []Tag
Favorite bool // Per requesting user Favorite bool // Per requesting user
Deleted bool
} }
// SearchFilters is a model which represents the required filters to complete a recipe search. // SearchFilters is a model which represents the required filters to complete a recipe search.
@ -155,7 +156,7 @@ type RecipeTag struct {
Created time.Time Created time.Time
} }
// TODO: Comment // TODO: Document
type CreateRecipeRequest struct { type CreateRecipeRequest struct {
Title string Title string
Description string Description string
@ -168,3 +169,18 @@ type CreateRecipeRequest struct {
Sections []RecipeIngredientSection Sections []RecipeIngredientSection
Tags []string 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
}

View File

@ -2,13 +2,17 @@ package domain
type RecipeRepository interface { type RecipeRepository interface {
CreateRecipe(recipe *Recipe) error CreateRecipe(recipe *Recipe) error
EditRecipe(recipe *Recipe, userId int) error
DeleteRecipe(recipeId, userId int) error
GetRecipe(id int, userId *int) (*Recipe, error) GetRecipe(id int, userId *int) (*Recipe, error)
GetRecipes(ids []int, userId *int) ([]Recipe, error) GetRecipes(ids []int, userId *int) ([]Recipe, error)
SearchRecipes(filters SearchFilters, userId *int, favorites bool) ([]int, error) SearchRecipes(filters SearchFilters, userId *int, favorites bool) ([]int, error)
CreateRecipeTags(recipe Recipe, tags []string) error CreateRecipeTags(recipe Recipe, tags []string) error
UpdateRecipeTags(recipe Recipe, tags []string) error
GetUserRecipesIds(userId int) ([]int, error) GetUserRecipesIds(userId int) ([]int, error)
GetUserFavoriteRecipesIds(userId int) ([]int, error) GetUserFavoriteRecipesIds(userId int) ([]int, error)
GetRecipeTags(recipe *Recipe) error GetRecipeTags(recipe *Recipe) error
GetRecipeFavorite(recipe *Recipe, userId int) error GetRecipeFavorite(recipe *Recipe, userId int) error
GetRecipeOfTheWeekId(userId *int) (*int, error) GetRecipeOfTheWeekId(userId *int) (*int, error)
IsRecipeOwner(userId, recipeId int) (bool, error)
} }

View File

@ -6,6 +6,8 @@ import (
type RecipeService interface { type RecipeService interface {
CreateRecipe(ctx *gin.Context) (*Recipe, error) 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) GetRecipe(id int, userId *int) (*Recipe, error)
SearchRecipes(filters SearchFilters, userId *int, favorites bool) ([]Recipe, error) SearchRecipes(filters SearchFilters, userId *int, favorites bool) ([]Recipe, error)
GetUserRecipes(userId int) ([]Recipe, error) GetUserRecipes(userId int) ([]Recipe, error)
@ -13,4 +15,5 @@ type RecipeService interface {
GetUserViewedRecipes(userId, limit int) ([]Recipe, error) GetUserViewedRecipes(userId, limit int) ([]Recipe, error)
GetUserMadeRecipes(userId, limit int) ([]Recipe, error) GetUserMadeRecipes(userId, limit int) ([]Recipe, error)
GetRecipeOfTheWeek(userId *int) (*Recipe, error) GetRecipeOfTheWeek(userId *int) (*Recipe, error)
IsRecipeOwner(userId *int, recipeId int) (bool, error)
} }

View File

@ -2,7 +2,6 @@ package domain
import ( import (
"fmt" "fmt"
"os"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
@ -11,19 +10,21 @@ import (
domainRecipe "github.com/haydenhargreaves/Potion/internal/domain/recipe" domainRecipe "github.com/haydenhargreaves/Potion/internal/domain/recipe"
domainUser "github.com/haydenhargreaves/Potion/internal/domain/user" domainUser "github.com/haydenhargreaves/Potion/internal/domain/user"
"github.com/joho/godotenv" "github.com/joho/godotenv"
"github.com/kelseyhightower/envconfig"
) )
// EnvironmentConfig stores the configuration of the environment. Anything loaded from the .env // 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 // or docker environment will be stored here and can be accessed from the InjectedDependencies
// struct, which this is attached to. // struct, which this is attached to.
type EnvironmentConfig struct { type EnvironmentConfig struct {
GoogleClientId string GoogleClientId string `envconfig:"GOOGLE_CLIENT_ID" required:"true"`
GoogleClientSecret string GoogleClientSecret string `envconfig:"GOOGLE_CLIENT_SECRET" required:"true"`
JwtSecret string JwtSecret string `envconfig:"JWT_SECRET" required:"true"`
DatabaseUrl string DatabaseUrl string `envconfig:"DATABASE_URL" required:"true"`
Environment string Domain string `envconfig:"DOMAIN" required:"true"`
Domain string FrontendDomain string `envconfig:"FRONTEND_DOMAIN" required:"true"`
FrontendDomain string Environment string `envconfig:"ENVIRONMENT" required:"true"`
LogFilePath string `envconfig:"LOG_FILE_PATH" required:"false"`
} }
// InjectedDependencies is a collection of dependencies that are injected into the application. They // InjectedDependencies is a collection of dependencies that are injected into the application. They
@ -51,87 +52,16 @@ func IsLoggedIn(ctx *gin.Context) bool {
return id && email return id && email
} }
// LoadEnvironment loads the environment values from either an .env file or docker environment. In // LoadEnvironment loads the environment values from either an .env file or docker environment.
// 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) { func LoadEnvironment() (*EnvironmentConfig, error) {
err := godotenv.Load(".env") // NOTE: Does the error return matter?
if err != nil { godotenv.Load(".env")
fmt.Printf("No .env file found or error loading .env: %v. Relying on system environment variables.", err)
}
env := os.Getenv("ENVIRONMENT") cfg := &EnvironmentConfig{}
if env == "" { if err := envconfig.Process("", cfg); err != nil {
return nil, fmt.Errorf("ENVIRONMENT environment variable is required.") return nil, fmt.Errorf("Failed to load environment: %w", err)
} }
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 return cfg, nil
} }

View File

@ -1,6 +1,6 @@
-- Author: Hayden Hargreaves (hhargreaves2006@gmail.com) -- Author: Hayden Hargreaves (hhargreaves2006@gmail.com)
-- Desc: Create the recipe of the week stored procedure. -- Desc: Create the recipe of the week stored procedure.
-- Date: 07/26/2025 -- Date: 07/26/2025, 1/10/2026
CREATE OR REPLACE PROCEDURE calculate_recipe_of_the_week_procedure() CREATE OR REPLACE PROCEDURE calculate_recipe_of_the_week_procedure()
LANGUAGE plpgsql LANGUAGE plpgsql
@ -20,6 +20,9 @@ BEGIN
NOW() NOW()
FROM FROM
Engagements e Engagements e
JOIN Recipes r
ON r.Id = e.Entity
AND r.Deleted = FALSE
WHERE WHERE
e.Created >= NOW() - INTERVAL '7 days' e.Created >= NOW() - INTERVAL '7 days'
AND e.Entity IS NOT NULL AND e.Entity IS NOT NULL

View File

@ -0,0 +1,13 @@
-- 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;

View File

@ -0,0 +1,10 @@
-- 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;

View File

@ -0,0 +1,14 @@
-- 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;

View File

@ -0,0 +1,14 @@
-- 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;

View File

@ -0,0 +1,10 @@
-- 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;

View File

@ -10,4 +10,9 @@ 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/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/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/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

View File

@ -7,11 +7,12 @@ import (
"time" "time"
domain "github.com/haydenhargreaves/Potion/internal/domain/engagement" domain "github.com/haydenhargreaves/Potion/internal/domain/engagement"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq" _ "github.com/lib/pq"
) )
type EngagementRepository struct { type EngagementRepository struct {
db *sql.DB db *sqlx.DB
} }
// Compile-time check to ensure the EngagementRepository implements domain.EngagementRepository // Compile-time check to ensure the EngagementRepository implements domain.EngagementRepository
@ -19,7 +20,7 @@ var _ domain.EngagementRepository = (*EngagementRepository)(nil)
// NewUserRepository creates a user repository object which is used by the user service to access // 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. // the database. Any user related database operations will take place in this repository.
func NewEngagementRepository(db *sql.DB) domain.EngagementRepository { func NewEngagementRepository(db *sqlx.DB) domain.EngagementRepository {
return &EngagementRepository{db: db} return &EngagementRepository{db: db}
} }
@ -31,7 +32,6 @@ func NewEngagementRepository(db *sql.DB) domain.EngagementRepository {
func (r *EngagementRepository) AddUserEngagement(userId int, message string, engagementType domain.EngagementType) (domain.Engagement, error) { func (r *EngagementRepository) AddUserEngagement(userId int, message string, engagementType domain.EngagementType) (domain.Engagement, error) {
tx, err := r.db.Begin() tx, err := r.db.Begin()
if err != nil { if err != nil {
tx.Rollback()
return domain.Engagement{}, err return domain.Engagement{}, err
} }
@ -58,7 +58,6 @@ func (r *EngagementRepository) AddUserEngagement(userId int, message string, eng
} }
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {
tx.Rollback()
return domain.Engagement{}, err return domain.Engagement{}, err
} }
@ -80,7 +79,6 @@ func (r *EngagementRepository) AddUserEngagement(userId int, message string, eng
func (r *EngagementRepository) AddUserEntityEngagement(userId, entityId int, message string, engagementType domain.EngagementType) (domain.Engagement, error) { func (r *EngagementRepository) AddUserEntityEngagement(userId, entityId int, message string, engagementType domain.EngagementType) (domain.Engagement, error) {
tx, err := r.db.Begin() tx, err := r.db.Begin()
if err != nil { if err != nil {
tx.Rollback()
return domain.Engagement{}, err return domain.Engagement{}, err
} }
@ -107,7 +105,6 @@ func (r *EngagementRepository) AddUserEntityEngagement(userId, entityId int, mes
} }
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {
tx.Rollback()
return domain.Engagement{}, err return domain.Engagement{}, err
} }
@ -141,7 +138,6 @@ func (r *EngagementRepository) AddEngagement(message string, engagementType doma
tx, err := r.db.Begin() tx, err := r.db.Begin()
if err != nil { if err != nil {
tx.Rollback()
return domain.Engagement{}, err return domain.Engagement{}, err
} }
@ -168,7 +164,6 @@ func (r *EngagementRepository) AddEngagement(message string, engagementType doma
} }
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {
tx.Rollback()
return domain.Engagement{}, err return domain.Engagement{}, err
} }
@ -202,7 +197,6 @@ func (r *EngagementRepository) AddEntityEngagement(entityId int, message string,
tx, err := r.db.Begin() tx, err := r.db.Begin()
if err != nil { if err != nil {
tx.Rollback()
return domain.Engagement{}, err return domain.Engagement{}, err
} }
@ -229,7 +223,6 @@ func (r *EngagementRepository) AddEntityEngagement(entityId int, message string,
} }
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {
tx.Rollback()
return domain.Engagement{}, err return domain.Engagement{}, err
} }
@ -339,7 +332,6 @@ func (r *EngagementRepository) GetUserEngagementFiltered(userId, limit int, enga
func (r *EngagementRepository) UserFavoriteRecipeToggle(userId, recipeId int) (bool, error) { func (r *EngagementRepository) UserFavoriteRecipeToggle(userId, recipeId int) (bool, error) {
tx, err := r.db.Begin() tx, err := r.db.Begin()
if err != nil { if err != nil {
tx.Rollback()
return false, err return false, err
} }

View File

@ -3,16 +3,18 @@ package repository
import ( import (
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"strings" "strings"
"time"
sq "github.com/Masterminds/squirrel"
domain "github.com/haydenhargreaves/Potion/internal/domain/recipe" domain "github.com/haydenhargreaves/Potion/internal/domain/recipe"
"github.com/jmoiron/sqlx"
"github.com/lib/pq" "github.com/lib/pq"
) )
type RecipeRepository struct { type RecipeRepository struct {
db *sql.DB db *sqlx.DB
} }
// Compile-time check to ensure the RecipeRepository implements domain.RecipeRepository // Compile-time check to ensure the RecipeRepository implements domain.RecipeRepository
@ -20,7 +22,7 @@ var _ domain.RecipeRepository = (*RecipeRepository)(nil)
// NewRecipeRepository creates a user repository object which is used by the user service to access // 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. // the database. Any recipe related database operations will take place in this repository.
func NewRecipeRepository(db *sql.DB) domain.RecipeRepository { func NewRecipeRepository(db *sqlx.DB) domain.RecipeRepository {
return &RecipeRepository{db: db} return &RecipeRepository{db: db}
} }
@ -31,26 +33,81 @@ func NewRecipeRepository(db *sql.DB) domain.RecipeRepository {
// be bubbled to the caller. The recipe parameter is passed by reference and will therefore be updated // be bubbled to the caller. The recipe parameter is passed by reference and will therefore be updated
// directly and the new fields (ID, created) can be accessed upon success. // directly and the new fields (ID, created) can be accessed upon success.
func (r *RecipeRepository) CreateRecipe(recipe *domain.Recipe) error { func (r *RecipeRepository) CreateRecipe(recipe *domain.Recipe) error {
tx, err := r.db.Begin() // Convert data into a readable format
durationJSON, err := json.Marshal(recipe.Duration)
if err != nil { if err != nil {
tx.Rollback()
return err return err
} }
query := `INSERT INTO recipes ( ingredientsStore := domain.RecipeIngredientStore{
title, description, instructions, serves, difficulty, Sections: recipe.Sections,
duration, category, ingredients, userid, modified, created Ingredients: recipe.Ingredients,
) VALUES ( }
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11
) RETURNING id;`
// NOTE: Data steps ingredientsJSON, err := json.Marshal(ingredientsStore)
// cast duration to JSON if err != nil {
// convert ingredients to store type return err
// cast store type to JSON }
// extract string instructions from type
// cast category to string instructions := make([]string, len(recipe.Instructions))
// use nil for the modified time for i, instruction := range recipe.Instructions {
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 := r.db.Get(&id, _sql, args...); err != nil {
return fmt.Errorf("Failed to create recipe: %w", err)
}
// Set the new ID
recipe.Id = id
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) durationJSON, err := json.Marshal(recipe.Duration)
if err != nil { if err != nil {
@ -72,32 +129,76 @@ func (r *RecipeRepository) CreateRecipe(recipe *domain.Recipe) error {
instructions[i] = instruction.Content instructions[i] = instruction.Content
} }
var id int psql := sq.StatementBuilder.PlaceholderFormat(sq.Dollar)
if err = tx.QueryRow(
query, query := psql.
recipe.Title, Update("recipes").
recipe.Description, Set("title", recipe.Title).
pq.Array(instructions), Set("description", recipe.Description).
recipe.Serves, Set("instructions", pq.Array(instructions)).
recipe.Difficulty, Set("serves", recipe.Serves).
durationJSON, Set("difficulty", recipe.Difficulty).
string(recipe.Category), Set("duration", durationJSON).
ingredientsJSON, Set("category", string(recipe.Category)).
recipe.UserId, Set("ingredients", ingredientsJSON).
nil, Set("modified", time.Now().UTC()).
recipe.Created, Where(sq.Eq{
).Scan(&id); err != nil { "id": recipe.Id,
tx.Rollback() "userid": userId,
return err })
_sql, args, err := query.ToSql()
if err != nil {
return fmt.Errorf("Failed to construct query: %w", err)
} }
if err := tx.Commit(); err != nil { result, err := r.db.Exec(_sql, args...)
tx.Rollback() if err != nil {
return err return fmt.Errorf("Failed to update recipe: %w", err)
} }
// Set the new ID if rows, err := result.RowsAffected(); err != nil {
recipe.Id = id 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 return nil
} }
@ -105,12 +206,15 @@ func (r *RecipeRepository) CreateRecipe(recipe *domain.Recipe) error {
// GetRecipe gets a recipe from the database via its ID. The operation is wrapped in a transaction // 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 // 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. // 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) { func (r *RecipeRepository) GetRecipe(id int, userId *int) (*domain.Recipe, error) {
query := ` SELECT query := `SELECT
id, title, description, instructions, serves, difficulty, duration, category, ingredients, id, title, description, instructions, serves, difficulty, duration, category, ingredients,
userid, modified, created userid, modified, created, deleted
FROM recipes FROM recipes
WHERE id = $1 WHERE id = $1 AND deleted = false;
` `
var durationBytes []byte var durationBytes []byte
@ -122,7 +226,6 @@ func (r *RecipeRepository) GetRecipe(id int, userId *int) (*domain.Recipe, error
&recipe.Id, &recipe.Id,
&recipe.Title, &recipe.Title,
&recipe.Description, &recipe.Description,
// pq.Array(&instructions),
&instructions, &instructions,
&recipe.Serves, &recipe.Serves,
&recipe.Difficulty, &recipe.Difficulty,
@ -132,8 +235,12 @@ func (r *RecipeRepository) GetRecipe(id int, userId *int) (*domain.Recipe, error
&recipe.UserId, &recipe.UserId,
&recipe.Modified, &recipe.Modified,
&recipe.Created, &recipe.Created,
&recipe.Deleted,
); err != nil { ); err != nil {
return nil, fmt.Errorf("Failed to location recipe in database: %s", err.Error()) if err == sql.ErrNoRows {
return nil, err
}
return nil, fmt.Errorf("Failed to location recipe (id: %d) in database: %s", id, err.Error())
} }
// Parse duration // Parse duration
@ -188,12 +295,15 @@ 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 // 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 // will. Callers are responsible for protecting against double nil results. Any errors will be bubbled
// to the caller. // 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) { func (r *RecipeRepository) GetRecipes(ids []int, userId *int) ([]domain.Recipe, error) {
var recipes []domain.Recipe var recipes []domain.Recipe
for _, id := range ids { for _, id := range ids {
recipe, err := r.GetRecipe(id, userId) recipe, err := r.GetRecipe(id, userId)
if err != nil { if err != nil && err != sql.ErrNoRows {
return nil, err return nil, err
} }
@ -220,182 +330,168 @@ func isBitActive(bits, pos int) bool {
// //
// TODO: Pagination is required, to provide infinite scroll. // 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 // 12/28/25: This function has changed, now longer returns the recipes, but their IDs for fetching
// elsewhere. // 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) { func (r *RecipeRepository) SearchRecipes(filters domain.SearchFilters, userId *int, favorites bool) ([]int, error) {
// Compute meals type filters (there are 7 bits) // Begin creating the query
var mealConditions []string psql := sq.StatementBuilder.PlaceholderFormat(sq.Dollar)
query := psql.Select("r.id").From("recipes r")
// Only select fields where the recipe ID can be found in the favorites table (mapped to user ID)
if favorites && userId != nil {
query = query.
Join("favorites f ON f.recipeId = r.id").
Where(sq.Eq{"f.userid": *userId})
}
// Compute and add meal type filters (7 bit options)
var mealCategories []string
for i := range 7 { for i := range 7 {
if isBitActive(filters.MealType, i) { if isBitActive(filters.MealType, i) {
mealConditions = append(mealConditions, fmt.Sprintf("category = '%s'", domain.ParseMeal(i))) mealCategories = append(mealCategories, string(domain.ParseMeal(i)))
} }
} }
// Compute time filters (there are 5 bits) if len(mealCategories) > 0 {
var timeConditions []string query = query.Where(sq.Eq{"category": mealCategories})
}
// Compute and add time filters (5 bit options)
var timeOr sq.Or
for i := range 5 { for i := range 5 {
var cond string
if isBitActive(filters.Time, i) { if isBitActive(filters.Time, i) {
switch i { switch i {
case 0: case 0:
cond = "(duration->>'total')::int < 15" timeOr = append(timeOr, sq.Lt{"(duration->>'total')::int": 15})
case 1: case 1:
cond = "(duration->>'total')::int BETWEEN 15 AND 30" timeOr = append(timeOr, sq.Expr("(duration->>'total')::int BETWEEN 15 AND 30"))
case 2: case 2:
cond = "(duration->>'total')::int BETWEEN 30 AND 60" timeOr = append(timeOr, sq.Expr("(duration->>'total')::int BETWEEN 30 AND 60"))
case 3: case 3:
cond = "(duration->>'total')::int BETWEEN 60 AND 120" timeOr = append(timeOr, sq.Expr("(duration->>'total')::int BETWEEN 60 AND 120"))
case 4: case 4:
cond = "(duration->>'total')::int > 120" timeOr = append(timeOr, sq.Gt{"(duration->>'total')::int": 120})
} }
timeConditions = append(timeConditions, cond)
} }
} }
// Compute difficulty filters (there are 5 bits) if len(timeOr) > 0 {
var difficultyConditions []string query = query.Where(timeOr)
}
// Compute and add difficulty filters (5 bit options)
var difficulties []int
for i := range 5 { for i := range 5 {
if isBitActive(filters.Difficulty, i) { if isBitActive(filters.Difficulty, i) {
cond := fmt.Sprintf("difficulty = '%d'", i+1) difficulties = append(difficulties, i+1)
difficultyConditions = append(difficultyConditions, cond)
} }
} }
// Compute serving size filters (there are 5 bits) if len(difficulties) > 0 {
var servingConditions []string query = query.Where(sq.Eq{"difficulty": difficulties})
}
// Compute and add serving size filters (5 bit options)
var servingOr sq.Or
for i := range 5 { for i := range 5 {
var cond string
if isBitActive(filters.ServingSize, i) { if isBitActive(filters.ServingSize, i) {
switch i { switch i {
case 0: case 0:
cond = "serves BETWEEN 1 AND 2" servingOr = append(servingOr, sq.Expr("serves BETWEEN 1 AND 2"))
case 1: case 1:
cond = "serves BETWEEN 2 AND 4" servingOr = append(servingOr, sq.Expr("serves BETWEEN 2 AND 4"))
case 2: case 2:
cond = "serves BETWEEN 4 AND 6" servingOr = append(servingOr, sq.Expr("serves BETWEEN 4 AND 6"))
case 3: case 3:
cond = "serves BETWEEN 6 AND 8" servingOr = append(servingOr, sq.Expr("serves BETWEEN 6 AND 8"))
case 4: case 4:
cond = "serves > 8" servingOr = append(servingOr, sq.Gt{"serves": 8})
} }
servingConditions = append(servingConditions, cond)
} }
} }
// Merge condition strings if len(servingOr) > 0 {
mealString := fmt.Sprintf("(%s)", strings.Join(mealConditions, " OR ")) query = query.Where(servingOr)
timeString := fmt.Sprintf("(%s)", strings.Join(timeConditions, " OR "))
difficultyString := fmt.Sprintf("(%s)", strings.Join(difficultyConditions, " OR "))
servingString := fmt.Sprintf("(%s)", strings.Join(servingConditions, " OR "))
// Combine condition strings
var conditions []string
if len(mealConditions) > 0 {
conditions = append(conditions, mealString)
}
if len(timeConditions) > 0 {
conditions = append(conditions, timeString)
}
if len(difficultyConditions) > 0 {
conditions = append(conditions, difficultyString)
}
if len(servingConditions) > 0 {
conditions = append(conditions, servingString)
} }
// Define columns to select. More fields can be added if the full text search is required // Handle search with full-text search and ILIKE fallback
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 != "" { if filters.Search != "" {
spl := strings.Split(filters.Search, " ") spl := strings.Split(filters.Search, " ")
var cleaned []string var cleaned []string
// Use a string replacer, each word in the query will be passed through this // Sanitize search terms
replacer := strings.NewReplacer( replacer := strings.NewReplacer(
"'", "", "'", "",
"-", "", "-", "",
"&", "", "&", "",
"|", "", "|", "",
"!", "", "!", "",
":", "",
"(", "",
")", "",
) )
for i := range len(spl) { for _, term := range spl {
q := strings.TrimSpace(replacer.Replace(spl[i])) q := strings.TrimSpace(replacer.Replace(term))
if q != "" { if q != "" {
cleaned = append(cleaned, q) cleaned = append(cleaned, q+":*") // Add prefix matching
} }
} }
vector_query := strings.Join(cleaned, " | ") if len(cleaned) > 0 {
vectorQuery := strings.Join(cleaned, " | ")
conditions = append( // Build search condition as raw SQL expression
conditions, // We'll use sq.Expr for the entire OR clause
fmt.Sprintf("r.search_vector @@ to_tsquery('english', '%s')", vector_query), var searchConditions []string
) var searchArgs []interface{}
template := ` // Full-text search
ORDER BY searchConditions = append(searchConditions, "r.search_vector @@ to_tsquery('english', ?)")
ts_rank(r.search_vector, to_tsquery('english', '%s')) DESC, searchArgs = append(searchArgs, vectorQuery)
ts_rank_cd(r.search_vector, to_tsquery('english', '%s')) DESC
` // ILIKE fallback for substring matching
orderBy = fmt.Sprintf(template, vector_query, vector_query) 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+"%")
}
} }
// Generate the query // Combine all conditions with OR
var query string searchExpr := fmt.Sprintf("(%s)", strings.Join(searchConditions, " OR "))
if favorites && userId != nil { query = query.Where(sq.Expr(searchExpr, searchArgs...))
query = fmt.Sprintf(
"SELECT %s FROM recipes r JOIN favorites f ON f.recipeId = r.id",
strings.Join(columns, ","),
)
// Add new favorite condition to the conditions list // Add ordering for search results
conditions = append(conditions, fmt.Sprintf("f.userid = %d", *userId)) query = query.
} else { OrderBy(fmt.Sprintf("CASE WHEN r.search_vector @@ to_tsquery('english', '%s') THEN 1 ELSE 2 END", vectorQuery)).
query = fmt.Sprintf("SELECT %s FROM recipes r", strings.Join(columns, ",")) 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))
}
} }
// Convert and append conditions if provided // Exclude deleted recipes
if len(conditions) > 0 { query = query.Where(sq.Eq{"deleted": false})
conditionsString := fmt.Sprintf("WHERE %s", strings.Join(conditions, " AND "))
query = fmt.Sprintf("%s %s", query, conditionsString)
}
// Append sorting order if exists sql, args, err := query.ToSql()
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 { if err != nil {
return []int{}, fmt.Errorf("failed to query recipes: %w", err) return nil, fmt.Errorf("Failed to build query: %w", err)
} }
defer rows.Close()
// Execute query using SQLX
var ids []int var ids []int
for rows.Next() { if err = r.db.Select(&ids, sql, args...); err != nil {
var id int return nil, fmt.Errorf("Failed to query recipes: %w", err)
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 return ids, nil
@ -410,7 +506,6 @@ func (r *RecipeRepository) SearchRecipes(filters domain.SearchFilters, userId *i
func (r *RecipeRepository) CreateRecipeTags(recipe domain.Recipe, tags []string) error { func (r *RecipeRepository) CreateRecipeTags(recipe domain.Recipe, tags []string) error {
tx, err := r.db.Begin() tx, err := r.db.Begin()
if err != nil { if err != nil {
tx.Rollback()
return err return err
} }
@ -460,33 +555,110 @@ func (r *RecipeRepository) CreateRecipeTags(recipe domain.Recipe, tags []string)
return nil 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 // 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 // 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. // 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. // 12/28/25: This now returns just the IDs, the service can handle fetching them.
func (r *RecipeRepository) GetUserRecipesIds(user_id int) ([]int, error) { //
query := ` // This function will only return recipes that are not deleted. Any recipes marked deleted will be ignored
SELECT id // and the standard "not-found" error will be returned.
FROM recipes func (r *RecipeRepository) GetUserRecipesIds(userId int) ([]int, error) {
WHERE userid = $1 psql := sq.StatementBuilder.PlaceholderFormat(sq.Dollar)
ORDER BY created DESC;
`
rows, err := r.db.Query(query, user_id) query := psql.
Select("id").
From("recipes").
Where(sq.Eq{
"userid": userId,
"deleted": false,
}).
OrderBy("created DESC")
_sql, args, err := query.ToSql()
if err != nil { if err != nil {
return nil, fmt.Errorf("Failed to query DB for user recipes. %s\n", err.Error()) return []int{}, fmt.Errorf("Failed to construct SQL query: %w", err)
} }
defer rows.Close()
var ids []int var ids []int
for rows.Next() { if err := r.db.Select(&ids, _sql, args...); err != nil {
var r_id int return []int{}, fmt.Errorf("Failed to get user recipes: %w", err)
if err := rows.Scan(&r_id); err != nil {
return []int{}, fmt.Errorf("Failed to scan ID from db. %s\n", err.Error())
}
ids = append(ids, r_id)
} }
return ids, nil return ids, nil
@ -497,28 +669,32 @@ func (r *RecipeRepository) GetUserRecipesIds(user_id int) ([]int, error) {
// is sorted by the created dates, newest first. Any errors will be bubbled to the caller. // 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. // 12/28/25: This now just returns the IDs, so the service can handle the fetching.
func (r *RecipeRepository) GetUserFavoriteRecipesIds(id int) ([]int, error) { //
query := ` // This function will only return recipes that are not deleted. Any recipes marked deleted will be ignored
SELECT r.id // and the standard "not-found" error will be returned.
FROM favorites f func (r *RecipeRepository) GetUserFavoriteRecipesIds(userId int) ([]int, error) {
JOIN recipes r ON r.id = f.recipeid psql := sq.StatementBuilder.PlaceholderFormat(sq.Dollar)
WHERE f.userid = $1
ORDER BY f.created DESC; query := psql.
` Select("r.id").
rows, err := r.db.Query(query, id) From("favorites f").
Join("recipes r on r.id = f.recipeid").
Where(sq.Eq{
"f.userid": userId,
"deleted": false,
}).
OrderBy("f.created DESC")
_sql, args, err := query.ToSql()
if err != nil { if err != nil {
return nil, fmt.Errorf("Failed to query DB for user recipes. %s\n", err.Error()) return []int{}, fmt.Errorf("Failed to construct SQL query: %w", err)
} }
defer rows.Close()
fmt.Println(_sql)
var ids []int var ids []int
for rows.Next() { if err := r.db.Select(&ids, _sql, args...); err != nil {
var r_id int return []int{}, fmt.Errorf("Failed to get users' favorite recipes: %w", err)
if err := rows.Scan(&r_id); err != nil {
return []int{}, fmt.Errorf("Failed to scan ID from db. %s\n", err.Error())
}
ids = append(ids, r_id)
} }
return ids, nil return ids, nil
@ -569,15 +745,24 @@ func (r *RecipeRepository) GetRecipeFavorite(recipe *domain.Recipe, userId int)
return nil return nil
} }
query := ` psql := sq.StatementBuilder.PlaceholderFormat(sq.Dollar)
SELECT COUNT(*)
FROM favorites query := psql.
WHERE recipeid = $1 AND userid = $2; Select("COUNT(*)").
` From("favorites").
Where(sq.Eq{
"recipeid": recipe.Id,
"userid": userId,
})
_sql, args, err := query.ToSql()
if err != nil {
return fmt.Errorf("Failed to construct SQL query: %w", err)
}
var count int var count int
if err := r.db.QueryRow(query, recipe.Id, userId).Scan(&count); err != nil { if err := r.db.Get(&count, _sql, args...); err != nil {
return fmt.Errorf("Failed to get recipe favorite. %s", err.Error()) return fmt.Errorf("Failed to get recipe favorite status: %w", err)
} }
recipe.Favorite = count > 0 recipe.Favorite = count > 0
@ -590,22 +775,57 @@ func (r *RecipeRepository) GetRecipeFavorite(recipe *domain.Recipe, userId int)
// table and return it. If there is no entry, nil will be returned. Any errors will be bubbled to // table and return it. If there is no entry, nil will be returned. Any errors will be bubbled to
// the caller. All that is returned is the recipe ID, that way the caller can handle the fetching. // the caller. All that is returned is the recipe ID, that way the caller can handle the fetching.
func (r *RecipeRepository) GetRecipeOfTheWeekId(userId *int) (*int, error) { func (r *RecipeRepository) GetRecipeOfTheWeekId(userId *int) (*int, error) {
query := ` psql := sq.StatementBuilder.PlaceholderFormat(sq.Dollar)
SELECT
r.id
FROM recipes r
JOIN recipeoftheweek rw ON rw.recipeid = r.id
ORDER BY rw.created DESC
LIMIT 1;
`
var id int query := psql.
if err := r.db.QueryRow(query).Scan(&id); err != nil { Select("r.id").
if errors.Is(err, sql.ErrNoRows) { From("recipes r").
Join("recipeoftheweek rw ON rw.recipeid = r.id").
Where(sq.Eq{"r.deleted": false}).
OrderBy("rw.created DESC").
Limit(1)
_sql, args, err := query.ToSql()
if err != nil {
return nil, fmt.Errorf("Failed to build SQL query: %w", err)
}
var recipeId int
if err := r.db.Get(&recipeId, _sql, args...); err != nil {
if err == sql.ErrNoRows {
return nil, nil return nil, nil
} }
return nil, fmt.Errorf("Failed to location recipe in database: %s", err.Error()) return nil, fmt.Errorf("Failed to locate recipe in database: %s", err.Error())
} }
return &id, nil return &recipeId, nil
}
// IsRecipeOwner takes two required arguments: a user id and a recipe id. This function queries the DB
// 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
} }

View File

@ -4,12 +4,14 @@ import (
"database/sql" "database/sql"
"fmt" "fmt"
sq "github.com/Masterminds/squirrel"
domain "github.com/haydenhargreaves/Potion/internal/domain/user" domain "github.com/haydenhargreaves/Potion/internal/domain/user"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq" _ "github.com/lib/pq"
) )
type UserRepository struct { type UserRepository struct {
db *sql.DB db *sqlx.DB
} }
// Compile-time check to ensure the UserRepository implements domain.UserRepository // Compile-time check to ensure the UserRepository implements domain.UserRepository
@ -17,7 +19,7 @@ var _ domain.UserRepository = (*UserRepository)(nil)
// NewUserRepository creates a user repository object which is used by the user service to access // 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. // the database. Any user related database operations will take place in this repository.
func NewUserRepository(db *sql.DB) domain.UserRepository { func NewUserRepository(db *sqlx.DB) domain.UserRepository {
return &UserRepository{db: db} return &UserRepository{db: db}
} }
@ -30,44 +32,37 @@ func NewUserRepository(db *sql.DB) domain.UserRepository {
// best results, pair this function with the GetGoogleUser which will return the user if it can find // best results, pair this function with the GetGoogleUser which will return the user if it can find
// it. // it.
func (r *UserRepository) CreateGoogleUser(googleUserInfo *domain.GoogleUserInfo, googleRefreshToken string) (domain.User, error) { 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 { if googleUserInfo == nil {
return domain.User{}, fmt.Errorf("Google user info provided was nil") return domain.User{}, fmt.Errorf("Google user info provided was nil")
} }
var user domain.User psql := sq.StatementBuilder.PlaceholderFormat(sq.Dollar)
query := `INSERT INTO users
(GoogleId, Name, Email, ImageUrl, GoogleRefreshToken)
VALUES ($1, $2, $3, $4, $5) RETURNING *;`
if err := tx.QueryRow( query := psql.
query, Insert("users").
Columns(
"googleid",
"name",
"email",
"imageurl",
"googlerefreshtoken").
Values(
googleUserInfo.Id, googleUserInfo.Id,
googleUserInfo.Name, googleUserInfo.Name,
googleUserInfo.Email, googleUserInfo.Email,
googleUserInfo.Picture, googleUserInfo.Picture,
googleRefreshToken, googleRefreshToken,
).Scan( ).
&user.Id, Suffix("RETURNING *")
&user.GoogleId,
&user.Name, _sql, args, err := query.ToSql()
&user.Email, if err != nil {
&user.ImageUrl, return domain.User{}, fmt.Errorf("Failed to construct sql query: %w", err)
&user.GoogleRefreshToken,
&user.Created,
); err != nil {
tx.Rollback()
return domain.User{}, err
} }
if err := tx.Commit(); err != nil { var user domain.User
tx.Rollback() if err := r.db.Get(&user, _sql, args...); err != nil {
return domain.User{}, err return domain.User{}, fmt.Errorf("Failed to create user: %w", err)
} }
return user, nil return user, nil
@ -77,23 +72,26 @@ func (r *UserRepository) CreateGoogleUser(googleUserInfo *domain.GoogleUserInfo,
// function is used when a user logs in with Google to prevent duplicate entries from being made. If // 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. // no user is found, this function will return a null pointer but not an error.
func (r *UserRepository) GetGoogleUser(googleId string) (*domain.User, error) { func (r *UserRepository) GetGoogleUser(googleId string) (*domain.User, error) {
var user domain.User psql := sq.StatementBuilder.PlaceholderFormat(sq.Dollar)
query := `SELECT * FROM users WHERE GoogleId = $1`
if err := r.db.QueryRow(query, googleId).Scan( query := psql.
&user.Id, Select("*").
&user.GoogleId, From("users").
&user.Name, Where(sq.Eq{
&user.Email, "GoogleId": googleId,
&user.ImageUrl, })
&user.GoogleRefreshToken,
&user.Created, _sql, args, err := query.ToSql()
); err != nil { if err != nil {
// If no user was found, don't error, just return return nil, fmt.Errorf("Failed to construct sql query: %w", err)
}
var user domain.User
if err := r.db.Get(&user, _sql, args...); err != nil {
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, nil return nil, nil
} }
return nil, err return nil, fmt.Errorf("Failed to get Google user: %w", err)
} }
return &user, nil return &user, nil
@ -104,18 +102,19 @@ func (r *UserRepository) GetGoogleUser(googleId string) (*domain.User, error) {
// Callers are responsible for protecting against double nil results. Any errors will be bubbled // Callers are responsible for protecting against double nil results. Any errors will be bubbled
// to the caller. // to the caller.
func (r *UserRepository) GetUser(id int) (*domain.User, error) { func (r *UserRepository) GetUser(id int) (*domain.User, error) {
query := "SELECT * FROM users WHERE id = $1" psql := sq.StatementBuilder.PlaceholderFormat(sq.Dollar)
query := psql.
Select("*").
From("users").
Where(sq.Eq{"id": id})
_sql, args, err := query.ToSql()
if err != nil {
return nil, fmt.Errorf("Failed to construct sql query: %w", err)
}
var user domain.User var user domain.User
if err := r.db.QueryRow(query, id).Scan( if err := r.db.Get(&user, _sql, args...); err != nil {
&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 no user was found, don't error, just return
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, nil return nil, nil

View File

@ -0,0 +1,63 @@
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...)
}
}

View File

@ -0,0 +1,59 @@
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)
}

View File

@ -0,0 +1,77 @@
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)
}
}

View File

@ -0,0 +1,61 @@
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
View File

@ -24,3 +24,4 @@ dist-ssr
*.sw? *.sw?
.env .env
.vite

View File

@ -13,6 +13,7 @@ import { use, type ReactNode } from 'react';
import { AuthContext } from './context/AuthContext'; import { AuthContext } from './context/AuthContext';
import RecipePage from './pages/Recipe'; import RecipePage from './pages/Recipe';
import SearchPage from './pages/Search'; import SearchPage from './pages/Search';
import AuthCallback from './pages/AuthCallback';
function ProtectedRoute({ children }: { children: ReactNode }) { function ProtectedRoute({ children }: { children: ReactNode }) {
const { isLoggedIn } = use(AuthContext) const { isLoggedIn } = use(AuthContext)
@ -37,6 +38,9 @@ function App() {
{/* Login page does not inherit WebLayout */} {/* Login page does not inherit WebLayout */}
<Route path="/v2/web/login" element={<LoginPage />} /> <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 path="/v2/web" element={<WebLayout />}>
<Route index element={<Navigate to={ROUTE_CONSTANTS.Home} replace />} /> <Route index element={<Navigate to={ROUTE_CONSTANTS.Home} replace />} />
<Route path="home" element={<Home />} /> <Route path="home" element={<Home />} />

View File

@ -0,0 +1,15 @@
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>
);
}

View File

@ -0,0 +1,15 @@
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>
);
}

View File

@ -43,7 +43,7 @@ export default function FavoriteButton({ favorite, id }: FavoriteButtonProps) {
return _favorite ? ( return _favorite ? (
<button <button
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" 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"
onClick={() => void clickHandler()} onClick={() => void clickHandler()}
> >
<svg className="h-6 text-red-500" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg className="h-6 text-red-500" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">

View File

@ -35,7 +35,7 @@ export default function MadeButton({ id }: MadeButtonProps) {
return ( return (
<button <button
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`} 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`}
onClick={() => void clickHandler()} onClick={() => void clickHandler()}
> >
<svg <svg

View File

@ -14,7 +14,6 @@ export default function ShareButton({ id }: ShareButtonProps) {
const clickHandler = async () => { const clickHandler = async () => {
if (clicked) return; if (clicked) return;
console.log(window.location);
// Copy first, so it feels fast // Copy first, so it feels fast
const url = `${window.location.origin}${ROUTE_CONSTANTS.Recipe(id)}`; const url = `${window.location.origin}${ROUTE_CONSTANTS.Recipe(id)}`;
@ -30,7 +29,7 @@ export default function ShareButton({ id }: ShareButtonProps) {
}; };
return clicked ? ( return clicked ? (
<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"> <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">
<svg className="h-7" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg className="h-7" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path <path
fillRule="evenodd" fillRule="evenodd"
@ -42,7 +41,7 @@ export default function ShareButton({ id }: ShareButtonProps) {
</button> </button>
) : ( ) : (
<button <button
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" 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"
onClick={() => void clickHandler()} onClick={() => void clickHandler()}
> >
<svg className="h-7" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg className="h-7" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">

View File

@ -39,7 +39,7 @@ export default function RecipeCardLarge({ recipe }: RecipeCardLargeProps) {
<p className="text-xs overflow-hidden whitespace-nowrap text-ellipsis"> <p className="text-xs overflow-hidden whitespace-nowrap text-ellipsis">
Serves {recipe.Serves} Serves {recipe.Serves}
</p> </p>
<p className="text-sm text-wrap w-80"> <p className="text-sm text-wrap w-80 break-all">
{recipe.Description} {recipe.Description}
</p> </p>
<div className="flex items-end justify-between"> <div className="flex items-end justify-between">

View File

@ -1,6 +1,6 @@
import { Reorder } from "motion/react"; import { Reorder } from "motion/react";
import InstructionElement from "./InstructionElement"; import InstructionElement from "./InstructionElement";
import type { Dispatch, SetStateAction } from "react"; import { type Dispatch, type SetStateAction } from "react";
import type { RecipeInstruction } from "../../types/recipe"; import type { RecipeInstruction } from "../../types/recipe";
import type { RecipeValidationEntry } from "../../pages/Create"; import type { RecipeValidationEntry } from "../../pages/Create";

View File

@ -0,0 +1,7 @@
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>
);
}

View File

@ -0,0 +1,8 @@
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>
);
}

View File

@ -0,0 +1,8 @@
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>
);
}

View File

@ -7,10 +7,9 @@ import { isApiError } from "../../types/api/error";
import type { Recipe } from "../../types/recipe"; import type { Recipe } from "../../types/recipe";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { FilterContext } from "../../context/FilterContext"; import { FilterContext } from "../../context/FilterContext";
import ROUTE_CONSTANTS from "../../types/routes";
interface RecipeSearchBarProps { interface RecipeSearchBarProps {
// filters: SearchFilters;
// setFilters: React.Dispatch<React.SetStateAction<SearchFilters>>;
redirect: boolean; redirect: boolean;
searchOnLoad: boolean; searchOnLoad: boolean;
favorites: boolean; favorites: boolean;
@ -30,7 +29,7 @@ export default function RecipeSearchBar({ redirect, searchOnLoad, favorites, set
// SERVER FUNCTIONS // SERVER FUNCTIONS
const fetchSearchResults = async () => { const fetchSearchResults = async () => {
if (redirect) { if (redirect) {
await navigate("/v2/web/search"); await navigate(ROUTE_CONSTANTS.Search);
return; return;
} }
@ -39,7 +38,7 @@ export default function RecipeSearchBar({ redirect, searchOnLoad, favorites, set
if (setLoading) setLoading(true); if (setLoading) setLoading(true);
try { try {
const result = await SearchRecipes(filters); const result = await SearchRecipes({ ...filters, Favorites: favorites });
if (isApiError(result)) { if (isApiError(result)) {
console.error(result.message); console.error(result.message);
return; return;
@ -76,14 +75,6 @@ export default function RecipeSearchBar({ redirect, searchOnLoad, favorites, set
void fetchSearchResults(); void fetchSearchResults();
}, [searchOnLoad]); }, [searchOnLoad]);
useEffect(() => {
setFilters({
...filters,
Favorites: favorites
});
}, [favorites]);
return ( return (
<form className="w-full px-4 my-8" onSubmit={(e) => void searchHandler(e)}> <form className="w-full px-4 my-8" onSubmit={(e) => void searchHandler(e)}>
<div className="flex w-full gap-x-2"> <div className="flex w-full gap-x-2">

View File

@ -0,0 +1,46 @@
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>
);
}

View File

@ -4,6 +4,7 @@ import type { SearchFilters } from "../types/search";
interface FilterContextType { interface FilterContextType {
filters: SearchFilters; filters: SearchFilters;
setFilters: (filters: SearchFilters) => void; setFilters: (filters: SearchFilters) => void;
resetFilters: () => void;
} }
export const FilterContext = createContext<FilterContextType>({ export const FilterContext = createContext<FilterContextType>({
@ -16,4 +17,5 @@ export const FilterContext = createContext<FilterContextType>({
Favorites: false, Favorites: false,
}, },
setFilters: () => { return }, setFilters: () => { return },
resetFilters: () => { return },
}); });

View File

@ -36,7 +36,7 @@ export function FilterProvider({ children }: { children: ReactNode }) {
}, [filters]); }, [filters]);
return ( return (
<FilterContext value={{ filters, setFilters }}> <FilterContext value={{ filters, setFilters, resetFilters: () => setFilters(DEFAULT_FILTERS) }}>
{children} {children}
</FilterContext> </FilterContext>
) )

View File

@ -72,6 +72,7 @@ export function useIngredients() {
return { return {
sections, sections,
ingredients, ingredients,
setIngredients,
setSections, setSections,
sectionChange, sectionChange,
ingredientChange, ingredientChange,

View File

@ -34,7 +34,7 @@ export function validateCreateRecipeForm(values: CreateRecipeFormValues, dirty:
servingSize: dirty.servingSize servingSize: dirty.servingSize
? values.servingSize !== "" && ? values.servingSize !== "" &&
Number(values.servingSize) >= 1 && Number(values.servingSize) >= 1 &&
Number(values.servingSize) <= 16 Number(values.servingSize) <= 127
: true, : true,
category: dirty.category category: dirty.category
? values.category !== "" && isRecipeMeal(values.category) ? values.category !== "" && isRecipeMeal(values.category)

View File

@ -9,12 +9,12 @@ export default function WebLayout() {
<div className="bg-gray-100 min-h-screen"> <div className="bg-gray-100 min-h-screen">
<Navigation /> <Navigation />
<div className="w-full flex justify-center"> <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"> <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">
<Outlet /> <Outlet />
</div> </div>
</div> </div>
</div> </div>
</> </>
); );
} }

View File

@ -0,0 +1,30 @@
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;
}

View File

@ -1,6 +1,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Banner from "../components/Banner"; import Banner from "../components/Banner";
import { isRecipeMeal, type RecipeInstruction } from "../types/recipe"; import { isRecipeMeal, type Recipe, type RecipeInstruction } from "../types/recipe";
import InstructionList from "../components/forms/InstructionList"; import InstructionList from "../components/forms/InstructionList";
import ValidationErrorList from "../components/forms/ValidationErrorList"; import ValidationErrorList from "../components/forms/ValidationErrorList";
import IngredientSection from "../components/forms/IngredientSection"; import IngredientSection from "../components/forms/IngredientSection";
@ -13,10 +13,10 @@ import RecipeCreateFormWrapper from "../components/inputs/RecipeCreateFormWrappe
import RecipeCreateFormTagsInputs from "../components/inputs/RecipeCreateFormTagsInput"; import RecipeCreateFormTagsInputs from "../components/inputs/RecipeCreateFormTagsInput";
import { useIngredients } from "../hooks/useIngredients"; import { useIngredients } from "../hooks/useIngredients";
import { validateCreateRecipeForm } from "../hooks/validation"; import { validateCreateRecipeForm } from "../hooks/validation";
import { CreateRecipe } from "../services/RecipeService"; import { CreateRecipe, EditRecipe, GetRecipe, IsRecipeOwner } from "../services/RecipeService";
import type { CreateRecipeRequest } from "../types/api/recipe"; import type { CreateRecipeRequest, EditRecipeRequest } from "../types/api/recipe";
import { isApiError } from "../types/api/error"; import { isApiError, type ApiError } from "../types/api/error";
import { useNavigate } from "react-router-dom"; import { useNavigate, useSearchParams } from "react-router-dom";
import ROUTE_CONSTANTS from "../types/routes"; import ROUTE_CONSTANTS from "../types/routes";
// TODO: Move these // TODO: Move these
@ -127,9 +127,8 @@ export default function Create() {
// Functions // Functions
const createRecipe = async (): Promise<void> => { const createRecipe = async (): Promise<void> => {
console.log({ title, description, tags, prepTime, cookTime, servingSize, category, difficulty, sections, ingredients, instructions });
// Exit if not valid recipe meal // Exit if not valid recipe meal
// This is a REQUIRED typescript check.
if (!isRecipeMeal(category)) { if (!isRecipeMeal(category)) {
console.error("[ERROR] Recipe meal is invalid."); console.error("[ERROR] Recipe meal is invalid.");
return; return;
@ -162,10 +161,53 @@ export default function Create() {
await navigate(ROUTE_CONSTANTS.Recipe(response.Id)); 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 // Import ingredients
const { const {
sections, sections,
ingredients, ingredients,
setIngredients,
setSections, setSections,
sectionChange, sectionChange,
ingredientChange, ingredientChange,
@ -246,8 +288,12 @@ export default function Create() {
return; return;
} }
if (editingId) {
void editRecipe();
} else {
void createRecipe(); void createRecipe();
} }
}
// EFFECTS // EFFECTS
@ -269,26 +315,115 @@ export default function Create() {
setIsFormValid(bools_valid && ingredients_valid && instructions_valid); setIsFormValid(bools_valid && ingredients_valid && instructions_valid);
}, [validation, dirty]); }, [validation, dirty]);
useEffect(() => { // EDITING IMPLEMENTATION
console.debug("@validation", validation); const [searchParams] = useSearchParams();
}, [validation]); 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(() => { useEffect(() => {
console.debug("@dirty", dirty); const id = Number(editingId);
}, [dirty]); if (!id) return;
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]);
return ( return (
<> <>
<Banner content="Create Your Masterpiece" /> <Banner content={editingId ? "Edit Your Recipe" : "Create Your Masterpiece"} />
<div className="mx-4 md:mx-16 my-8"> <div className="mx-4 md:mx-16 my-8">
<p className="mb-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, 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, 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 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 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" marked with an <span className="text-red-500">*</span>. Once everything looks perfect, just
button to hit the "Create Recipe" button to share your masterpiece!
share your masterpiece! </>
)}
</p> </p>
<div> <div>
{/* Title Input */} {/* Title Input */}
@ -385,7 +520,7 @@ export default function Create() {
error="Please enter a serving size." error="Please enter a serving size."
parentClasses="flex-grow w-1/3" parentClasses="flex-grow w-1/3"
min="1" min="1"
max="16" max="127"
classes={INPUT_CLASSES} classes={INPUT_CLASSES}
/> />
</div> </div>
@ -523,7 +658,7 @@ export default function Create() {
disabled={!isFormValid} 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`} 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`}
> >
Create Recipe {editingId ? "Save Changes" : "Create Recipe"}
</button> </button>
</div> </div>
</div> </div>

View File

@ -12,10 +12,13 @@ import { GetRecipeOfTheWeek } from "../services/RecipeService";
import { isApiError, type ApiError } from "../types/api/error"; import { isApiError, type ApiError } from "../types/api/error";
import { AuthContext } from "../context/AuthContext"; import { AuthContext } from "../context/AuthContext";
import { GetAuthenticatedUserMadeRecipes, GetAuthenticateUserViewedRecipes } from "../services/UserService"; import { GetAuthenticatedUserMadeRecipes, GetAuthenticateUserViewedRecipes } from "../services/UserService";
import { useNavigate } from "react-router-dom";
import { FilterContext } from "../context/FilterContext";
export default function Home() { export default function Home() {
// Context // Context
const { isLoggedIn } = use(AuthContext); const { isLoggedIn } = use(AuthContext);
const { resetFilters } = use(FilterContext);
// Page state // Page state
const [recipeOfTheWeek, setRecipeOfTheWeek] = useState<Recipe | null>(null); const [recipeOfTheWeek, setRecipeOfTheWeek] = useState<Recipe | null>(null);
@ -24,6 +27,7 @@ export default function Home() {
const [viewedRecipes, setViewedRecipes] = useState<Recipe[]>([]); const [viewedRecipes, setViewedRecipes] = useState<Recipe[]>([]);
const [error, setError] = useState<string>(""); const [error, setError] = useState<string>("");
const navigate = useNavigate();
// Fetch the recipe of the week // Fetch the recipe of the week
useEffect(() => { useEffect(() => {
@ -55,6 +59,12 @@ export default function Home() {
void fetch(); void fetch();
}, [isLoggedIn]); }, [isLoggedIn]);
const viewAllRecipesHandler = () => {
// Clear filters
resetFilters();
void navigate(ROUTE_CONSTANTS.Search);
}
// BUG: Prob remove // BUG: Prob remove
useEffect(() => { useEffect(() => {
if (error) if (error)
@ -88,7 +98,15 @@ export default function Home() {
<div className="w-full md:w-3/4"> <div className="w-full md:w-3/4">
<RecipeSearchBar redirect={true} favorites={false} searchOnLoad={false} setRecipes={null} /> <RecipeSearchBar redirect={true} favorites={false} searchOnLoad={false} setRecipes={null} />
</div> </div>
<div className="hidden" id="result-list"></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>
</section> </section>
{/* Highlight Section */} {/* Highlight Section */}

View File

@ -115,8 +115,8 @@ export default function Profile() {
<p className="text-xs md:text-sm">{user?.Email}</p> <p className="text-xs md:text-sm">{user?.Email}</p>
</div> </div>
<div className="flex gap-x-4"> <div className="flex gap-x-4">
<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">{recipes?.length ?? 0}</span> recipes</p>
<p className="text-xs md:text-sm"><span className="font-bold">{favorites.length}</span> favorites</p> <p className="text-xs md:text-sm"><span className="font-bold">{favorites?.length ?? 0}</span> favorites</p>
</div> </div>
</div> </div>
</div> </div>
@ -126,10 +126,10 @@ export default function Profile() {
<section className="p-8"> <section className="p-8">
<h2 className="text-2xl font-semibold text-gray-800">My Recipes</h2> <h2 className="text-2xl font-semibold text-gray-800">My Recipes</h2>
<ul className="w-full my-2"> <ul className="w-full my-2">
{recipes.length <= 4 ? ( {recipes?.length <= 4 ? (
recipes.map(recipe => <RecipeListItem key={recipe.Id} recipe={recipe} />) 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"> <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"> <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"> <section className="p-8">
<h2 className="text-2xl font-semibold text-gray-800">My Favorites</h2> <h2 className="text-2xl font-semibold text-gray-800">My Favorites</h2>
<ul className="w-full my-2"> <ul className="w-full my-2">
{favorites.length <= 4 ? ( {favorites?.length <= 4 ? (
favorites.map(recipe => <RecipeListItem key={recipe.Id} recipe={recipe} />) 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"> <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"> <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">

View File

@ -1,8 +1,8 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { isApiError, type ApiError } from "../types/api/error"; import { isApiError, type ApiError } from "../types/api/error";
import { GetRecipe } from "../services/RecipeService"; import { DeleteRecipe, GetRecipe, IsRecipeOwner } from "../services/RecipeService";
import type { Recipe } from "../types/recipe"; import type { Recipe } from "../types/recipe";
import { useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import RecipePlaceholder from "../assets/images/recipe_placeholder_wide.jpg" import RecipePlaceholder from "../assets/images/recipe_placeholder_wide.jpg"
import RecipeMetaData from "../components/display/RecipeMetaData"; import RecipeMetaData from "../components/display/RecipeMetaData";
@ -15,6 +15,10 @@ import InstructionList from "../components/items/InstructionList";
import Spinner from "../components/Spinner"; import Spinner from "../components/Spinner";
import { GetUser } from "../services/UserService"; import { GetUser } from "../services/UserService";
import type { User } from "../types/user"; 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() { export default function RecipePage() {
// Url params // Url params
@ -25,32 +29,78 @@ export default function RecipePage() {
const [author, setAuthor] = useState<User | null>(null); const [author, setAuthor] = useState<User | null>(null);
const [error, setError] = useState<string>(""); const [error, setError] = useState<string>("");
useEffect(() => { const [isAuthor, setIsAuthor] = useState<boolean>(false);
async function fetch() { const [isDeleting, setIsDeleting] = useState<boolean>(false);
const result: Recipe | ApiError = await GetRecipe(Number(id));
const navigate = useNavigate();
// Functions
const getRecipe = async (id: number) => {
const result: Recipe | ApiError = await GetRecipe(id);
if (isApiError(result)) { if (isApiError(result)) {
setError(result.message); setError(result.message);
} else { } else {
setRecipe(result); setRecipe(result);
} }
} }
void fetch();
}, [id]);
useEffect(() => { const getAuthor = async (id: number) => {
async function fetch() { const result: User | ApiError = await GetUser(id);
if (!recipe) return;
const result: User | ApiError = await GetUser(recipe.UserId);
if (isApiError(result)) { if (isApiError(result)) {
setError(result.message); setError(result.message);
} else { } else {
setAuthor(result); setAuthor(result);
} }
} }
void fetch();
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));
}, [id]);
useEffect(() => {
if (recipe)
void getAuthor(recipe.UserId);
}, [recipe]); }, [recipe]);
useEffect(() => {
void getIsAuthor();
}, [recipe, author]);
// BUG: Prob remove // BUG: Prob remove
useEffect(() => { useEffect(() => {
if (error) if (error)
@ -59,6 +109,12 @@ export default function RecipePage() {
return recipe ? ( 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} /> <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"> <div className="px-4 py-8 md:px-8">
<h1 className="text-3xl md:text-4xl font-bold text-gray-800">{recipe.Title}</h1> <h1 className="text-3xl md:text-4xl font-bold text-gray-800">{recipe.Title}</h1>
@ -66,14 +122,21 @@ export default function RecipePage() {
<p className="text-sm mb-2 text-gray-700">Category: {recipe.Category}</p> <p className="text-sm mb-2 text-gray-700">Category: {recipe.Category}</p>
</div> </div>
<RecipeMetaData recipe={recipe} /> <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"> <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">
<FavoriteButton favorite={recipe.Favorite} id={recipe.Id} /> <FavoriteButton favorite={recipe.Favorite} id={recipe.Id} />
<MadeButton id={recipe.Id} /> <MadeButton id={recipe.Id} />
<ShareButton id={recipe.Id} /> <ShareButton id={recipe.Id} />
{isAuthor && (
<>
<DeleteButton clickHandler={deleteHandler} />
<EditButton clickHandler={editHandler} />
</>
)}
</section> </section>
<div className="px-4 py-8 md:px-8"> <div className="px-4 py-8 md:px-8">
<h3 className="text-xl text-gray-800 font-semibold mb-2">About this recipe</h3> <h3 className="text-xl text-gray-800 font-semibold mb-2">About this recipe</h3>
<p className="text-gray-700">{recipe.Description}</p> <p className="text-gray-700 whitespace-pre-line wrap-break-word">{recipe.Description}</p>
</div> </div>
<IngredientList sections={recipe.Sections} ingredients={recipe.Ingredients} /> <IngredientList sections={recipe.Sections} ingredients={recipe.Ingredients} />
<InstructionList instructions={recipe.Instructions} /> <InstructionList instructions={recipe.Instructions} />

View File

@ -1,5 +1,5 @@
import axios from "axios"; import axios from "axios";
import type { CreateRecipeRequest, CreateRecipeResponse, GetRecipeOfTheWeekResponse, GetRecipeResponse, SearchRecipesResponse } from "../types/api/recipe"; import type { CreateRecipeRequest, CreateRecipeResponse, DeleteRecipeResponse, EditRecipeRequest, GetRecipeOfTheWeekResponse, GetRecipeResponse, IsRecipeOwnerResponse, SearchRecipesResponse, EditRecipeResponse } from "../types/api/recipe";
import type { Recipe } from "../types/recipe"; import type { Recipe } from "../types/recipe";
import type { ApiError } from "../types/api/error"; import type { ApiError } from "../types/api/error";
import type { SearchFilters } from "../types/search"; import type { SearchFilters } from "../types/search";
@ -63,3 +63,45 @@ export async function CreateRecipe(data: CreateRecipeRequest): Promise<Recipe |
return response.data.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;
}

View File

@ -24,6 +24,12 @@ export interface CreateRecipeResponse {
recipe?: Recipe; recipe?: Recipe;
} }
export interface EditRecipeResponse {
status: number;
message: string;
recipe?: Recipe;
}
export interface CreateRecipeRequest { export interface CreateRecipeRequest {
Title: string; Title: string;
Description: string; Description: string;
@ -36,3 +42,28 @@ export interface CreateRecipeRequest {
Sections: RecipeIngredientSection[]; Sections: RecipeIngredientSection[];
Tags: string[]; 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;
}

View File

@ -1,5 +1,5 @@
export type EngagementType = "made" | "liked" | "viewed" | "shared" | "reviewed" | "rated"; export type EngagementType = "made" | "liked" | "viewed" | "shared" | "reviewed" | "rated" | "created" | "deleted" | "edited";
export interface Engagement { export interface Engagement {
Id: number; Id: number;

View File

@ -1,4 +0,0 @@
export interface SearchFilters {
}

View File

View File

@ -93,4 +93,5 @@ export interface Recipe {
Created: Date; Created: Date;
Tags: Tag[]; Tags: Tag[];
Favorite: boolean; Favorite: boolean;
Deleted: boolean;
} }

View File

@ -8,7 +8,9 @@ const ROUTE_CONSTANTS: {
ShoppingList: string; ShoppingList: string;
Login: string; Login: string;
History: string; History: string;
Search: string;
Recipe: (id: number) => string; Recipe: (id: number) => string;
Edit: (id: number) => string;
} = { } = {
Home: `${VERSION_FLAG}/web/home`, Home: `${VERSION_FLAG}/web/home`,
Favorites: `${VERSION_FLAG}/web/favorites`, Favorites: `${VERSION_FLAG}/web/favorites`,
@ -17,7 +19,9 @@ const ROUTE_CONSTANTS: {
ShoppingList: `${VERSION_FLAG}/web/list`, ShoppingList: `${VERSION_FLAG}/web/list`,
Login: `${VERSION_FLAG}/web/login`, Login: `${VERSION_FLAG}/web/login`,
History: `${VERSION_FLAG}/web/history`, History: `${VERSION_FLAG}/web/history`,
Search: `${VERSION_FLAG}/web/search`,
Recipe: (id: number) => `${VERSION_FLAG}/web/recipe/${id}`, Recipe: (id: number) => `${VERSION_FLAG}/web/recipe/${id}`,
Edit: (id: number) => `${VERSION_FLAG}/web/create?edit=${id}`,
}; };
export default ROUTE_CONSTANTS; export default ROUTE_CONSTANTS;