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
/.env
/*.dump
/*.log

View File

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

View File

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

6
go.mod
View File

@ -15,6 +15,7 @@ require (
require (
cloud.google.com/go/compute/metadata v0.3.0 // indirect
github.com/Masterminds/squirrel v1.5.4 // indirect
github.com/bytedance/sonic v1.13.2 // indirect
github.com/bytedance/sonic/loader v0.2.4 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect
@ -24,9 +25,14 @@ require (
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.26.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jmoiron/sqlx v1.4.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/kelseyhightower/envconfig v1.4.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect

16
go.sum
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/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM=
github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10=
github.com/a-h/templ v0.3.898 h1:g9oxL/dmM6tvwRe2egJS8hBDQTncokbMoOFk1oJMX7s=
github.com/a-h/templ v0.3.898/go.mod h1:oLBbZVQ6//Q6zpvSMPTuBK0F3qOtBdFBcGRspcT+VNQ=
github.com/a-h/templ v0.3.920 h1:IQjjTu4KGrYreHo/ewzSeS8uefecisPayIIc9VflLSE=
@ -48,6 +51,7 @@ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBEx
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
@ -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/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o=
github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8=
github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
@ -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/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw=
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o=
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk=
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@ -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.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=

View File

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

View File

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

View File

@ -2,13 +2,13 @@ package server
import (
"fmt"
"log"
"net/http"
"runtime/debug"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
domain "github.com/haydenhargreaves/Potion/internal/domain/server"
"github.com/haydenhargreaves/Potion/internal/infrastructure/logging"
)
// DepedencyInjectionMiddleware injects the dependencies into the context set. This is a middleware
@ -77,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) {
defer func() {
if r := recover(); r != nil {
// Log the panic with stack trace
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{
"status": http.StatusOK,

View File

@ -3,10 +3,12 @@ package server
import (
"fmt"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
domain "github.com/haydenhargreaves/Potion/internal/domain/server"
"github.com/haydenhargreaves/Potion/internal/infrastructure/logging"
)
// JwtAuthMiddlewareV2 is responsible for protecting routes. Anything that may go wrong
@ -64,9 +66,9 @@ func JwtAuthMiddlewareV2(jwtSecretKey []byte) gin.HandlerFunc {
}
}
// JwtOptionalAuthMiddlewareV2 is responsible for collecting user data for routes where
// authentication is optional. Meaning: if the use is not logged in, this function does
// not fail or return, it simply does nothing. But if the user is logged in, then the
// JwtOptionalAuthMiddlewareV2 is responsible for collecting user data for routes where
// authentication is optional. Meaning: if the use is not logged in, this function does
// not fail or return, it simply does nothing. But if the user is logged in, then the
// 'userId' and 'userEmail' context values are set.
//
// e.g., `userIdAny, exists := ctx.Get("userId")`
@ -96,3 +98,34 @@ func JwtOptionalAuthMiddlewareV2(jwtSecretKey []byte) gin.HandlerFunc {
ctx.Next()
}
}
func LoggingMiddleware(logs []logging.Logger) gin.HandlerFunc {
// TODO: Need traces using IDs?
return func(ctx *gin.Context) {
start := time.Now()
ctx.Next()
var (
status int = ctx.Writer.Status()
latency string = time.Since(start).String()
client string = ctx.ClientIP()
method string = ctx.Request.Method
path string = ctx.Request.URL.Path
)
// TODO: Add color to status
format := "%d | %-14s | %15s | %-9s \"%s\""
logging.LogAll(
logs,
logging.LogLevelInfo,
format,
status,
latency,
client,
method,
path,
)
}
}

View File

@ -7,6 +7,7 @@ import (
"github.com/gin-gonic/gin"
domain "github.com/haydenhargreaves/Potion/internal/domain/recipe"
domainUser "github.com/haydenhargreaves/Potion/internal/domain/user"
)
// GetRecipeOfTheWeekHandler fetchs the current recipe of the week and returns it.
@ -96,6 +97,15 @@ func (s *Server) SearchRecipeHandlerV2(ctx *gin.Context) {
}
func (s *Server) CreateRecipeHandlerV2(ctx *gin.Context) {
userId := getUserId(ctx)
if userId == nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": "[ERROR] User must be logged in to create a recipe.",
})
return
}
recipe, err := s.deps.RecipeService.CreateRecipe(ctx)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
@ -105,9 +115,116 @@ func (s *Server) CreateRecipeHandlerV2(ctx *gin.Context) {
return
}
_, err = s.deps.EngagementService.UserCreateRecipe(*userId, recipe.Id)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[ERROR] Failed to create recipe engagement. %s", err.Error()),
})
return
}
ctx.JSON(http.StatusOK, gin.H{
"status": http.StatusOK,
"message": "[OK] Successfully created new recipe.",
"recipe": recipe,
})
}
func (s *Server) EditRecipeHandlerV2(ctx *gin.Context) {
s.withAuthenticatedUser(ctx, func(ctx *gin.Context, user *domainUser.User) {
id := ctx.Param("id")
parsedId, err := strconv.Atoi(id)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[ERROR] Failed to parse ID parameter. %s", err.Error()),
})
return
}
recipe, err := s.deps.RecipeService.EditRecipe(ctx, parsedId, user.Id)
_, err = s.deps.EngagementService.UserEditRecipe(user.Id, recipe.Id)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[ERROR] Failed to create recipe engagement. %s", err.Error()),
})
return
}
ctx.JSON(http.StatusOK, gin.H{
"status": http.StatusOK,
"message": "[OK] Successfully updated recipe.",
"recipe": recipe,
})
})
}
func (s *Server) DeleteRecipeHandlerV2(ctx *gin.Context) {
s.withAuthenticatedUser(ctx, func(ctx *gin.Context, user *domainUser.User) {
id := ctx.Param("id")
parsedId, err := strconv.Atoi(id)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[ERROR] Failed to parse ID parameter. %s", err.Error()),
})
return
}
_, err = s.deps.EngagementService.UserDeleteRecipe(user.Id, parsedId)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[ERROR] Failed to create recipe engagement. %s", err.Error()),
})
return
}
if err := s.deps.RecipeService.DeleteRecipe(user.Id, parsedId); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[ERROR] Failed to delete recipe. %s", err.Error()),
})
return
}
ctx.JSON(http.StatusOK, gin.H{
"status": http.StatusOK,
"message": "[OK] Successfully deleted recipe.",
})
})
}
func (s *Server) IsRecipeOwnerV2(ctx *gin.Context) {
userId := getUserId(ctx)
id := ctx.Param("id")
parsedId, err := strconv.Atoi(id)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"owner": false,
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[ERROR] Failed to parse ID parameter. %s", err.Error()),
})
return
}
isOwner, err := s.deps.RecipeService.IsRecipeOwner(userId, parsedId)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"owner": false,
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[ERROR] Failed to determine is user is recipe owner.", err.Error()),
})
return
}
ctx.JSON(http.StatusOK, gin.H{
"owner": isOwner,
"status": http.StatusOK,
"message": "[OK] Successfully determined recipe ownership status.",
})
}

View File

@ -1,10 +1,12 @@
package server
import (
"database/sql"
"fmt"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"github.com/a-h/templ/examples/integration-gin/gintemplrenderer"
"github.com/gin-contrib/cors"
@ -13,28 +15,39 @@ import (
domain "github.com/haydenhargreaves/Potion/internal/domain/server"
"github.com/haydenhargreaves/Potion/internal/infrastructure/auth"
"github.com/haydenhargreaves/Potion/internal/infrastructure/database/repository"
"github.com/haydenhargreaves/Potion/internal/infrastructure/logging"
"github.com/haydenhargreaves/Potion/internal/infrastructure/logging/loggers"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
)
type Server struct {
port int
Router *gin.Engine
config cors.Config
DB *sql.DB
deps domain.InjectedDependencies
port int
Router *gin.Engine
config cors.Config
DB *sqlx.DB
deps domain.InjectedDependencies
logs []logging.Logger
cleanupFuncs []func() error
}
// Init initializes the server with the provided port. CORS settings are defined here.
// A pointer to a server object is returned which allows for method chaining.
func Init(port int) *Server {
server := &Server{
Router: gin.Default(),
port: port,
config: cors.DefaultConfig(),
Router: gin.New(), // Not default anymore, to allow for custom logger
port: port,
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
// TODO: Remove this
htmlRenderer := server.Router.HTMLRender
server.Router.HTMLRender = &gintemplrenderer.HTMLTemplRenderer{FallbackHtmlRenderer: htmlRenderer}
@ -49,32 +62,59 @@ func Init(port int) *Server {
server.config.AllowCredentials = true
server.Router.Use(cors.New(server.config))
// We can use release mode since we don't need Gin's logging
gin.SetMode(gin.ReleaseMode)
return server
}
// Start starts the server on the port provided when the server was initialized
func (s *Server) Start() {
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")
}
// TODO: (9/4/2025) Abstract these functions and cleanup. This is fucking messy...
func (s *Server) cleanup() {
for _, cleanup := range s.cleanupFuncs {
// NOTE: Ignoring error
cleanup()
}
if s.DB != nil {
s.DB.Close()
}
}
// TODO: (9/4/2025) Abstract these functions and cleanup. This is fucking messy...
// TODO: (1/26/2026) Abstract these functions and cleanup. This is fucking messy... still
func (s *Server) Setup() *Server {
// SETUP THE ENVIRONMENT CONFIGURATION
cfg, err := domain.LoadEnvironment()
if err != nil {
logging.LogAll(s.logs, logging.LogLevelFatal, err.Error())
panic(err.Error())
}
if cfg == nil {
logging.LogAll(s.logs, logging.LogLevelFatal, "Environment configuration is nil, crashing.")
panic("Environment configuration is nil, crashing.")
}
if cfg.Environment == "dev" {
gin.SetMode(gin.DebugMode)
} else if cfg.Environment == "prod" {
gin.SetMode(gin.ReleaseMode)
} else {
gin.SetMode(gin.TestMode)
}
logging.LogAll(s.logs, logging.LogLevelDebug, "env: %+v\n", cfg)
// SETUP GOOGLE AUTH
var (
@ -92,7 +132,7 @@ func (s *Server) Setup() *Server {
auth.NewGoogleConfig(redirectUrl, clientId, clientSecret, scope)
// SETUP DATABASE
db, err := sql.Open("postgres", cfg.DatabaseUrl)
db, err := sqlx.Open("postgres", cfg.DatabaseUrl)
if err != nil {
panic("Could not connect to database: " + err.Error())
}
@ -103,6 +143,26 @@ func (s *Server) Setup() *Server {
s.DB = db
// TODO: Implement environment here for logging file
if cfg.LogFilePath != "" {
fileLogger, cleanup, err := loggers.NewFileLogger(cfg.LogFilePath, logging.LogLevelDebug)
if err != nil {
logging.LogAll(s.logs, logging.LogLevelWarning, "Failed to create file logger. %s\n", err.Error())
} else {
logging.LogAll(s.logs, logging.LogLevelDebug, "Initialized file logger on file '%s'\n", cfg.LogFilePath)
s.logs = append(s.logs, fileLogger)
s.cleanupFuncs = append(s.cleanupFuncs, cleanup)
}
}
databaseLogger, err := loggers.NewDatabaseLogger(s.DB, "logs", logging.LogLevelInfo)
if err != nil {
logging.LogAll(s.logs, logging.LogLevelWarning, "Failed to create database logger. %s\n", err.Error())
} else {
s.logs = append(s.logs, databaseLogger)
}
// SETUP JWT
jwtSecret := []byte(cfg.JwtSecret)
@ -111,7 +171,7 @@ func (s *Server) Setup() *Server {
recipeRepo := repository.NewRecipeRepository(s.DB)
engagementRepo := repository.NewEngagementRepository(s.DB)
userService := service.NewUserService(userRepo)
authService := service.NewAuthService(userRepo, jwtSecret)
authService := service.NewAuthService(userRepo, jwtSecret, s.logs)
recipeService := service.NewRecipeService(recipeRepo, engagementRepo)
engagementService := service.NewEngagementService(engagementRepo, recipeRepo)
@ -124,9 +184,7 @@ func (s *Server) Setup() *Server {
}
// Apply middleware
s.Router.Use(RecoveryMiddleware())
// NOTE: No longer running on every connection!
// s.Router.Use(JwtAuthMiddleWare(jwtSecret))
s.Router.Use(gin.Recovery(), RecoveryMiddleware(s.logs), LoggingMiddleware(s.logs))
// Redirect index to home page: Update this as needed
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 ---- //
router_api_v2 := router_v2.Group(domain.API)
router_api_v2.POST("/recipe", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.CreateRecipeHandlerV2)
router_api_v2.GET("/recipe/:id", JwtOptionalAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.GetRecipeHandlerV2)
router_api_v2.PUT("/recipe/:id", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.EditRecipeHandlerV2)
router_api_v2.GET("/recipe/of-the-week", JwtOptionalAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.GetRecipeOfTheWeekHandlerV2)
router_api_v2.POST("/recipe/search", JwtOptionalAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.SearchRecipeHandlerV2)
router_api_v2.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/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/viewed", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.GetAuthenicatedUserViewedRecipesV2)
router_api_v2.GET("/protected", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), func(ctx *gin.Context) {
ctx.JSON(http.StatusOK, gin.H{"msg": "YAY"})
})
router_api_v2.POST("/engagement/view/:id", JwtOptionalAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.EngagementViewRecipeHandlerV2)
router_api_v2.POST("/engagement/share/:id", JwtOptionalAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.EngagementShareRecipeHandlerV2)
router_api_v2.POST("/engagement/favorite/:id", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.EngagementFavoriteRecipeHandlerV2)
router_api_v2.POST("/engagement/make/:id", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.EngagementMakeRecipeHandlerV2)
if cfg.Environment == "dev" {
s.debugDisplayRoutes()
}
return s
}
func (s *Server) debugDisplayRoutes() {
for _, route := range s.Router.Routes() {
format := "%-8s %s"
logging.LogAll(s.logs, logging.LogLevelDebug, format, route.Method, route.Path)
}
}

View File

@ -10,6 +10,7 @@ import (
domainServer "github.com/haydenhargreaves/Potion/internal/domain/server"
domain "github.com/haydenhargreaves/Potion/internal/domain/user"
"github.com/haydenhargreaves/Potion/internal/infrastructure/auth"
"github.com/haydenhargreaves/Potion/internal/infrastructure/logging"
"golang.org/x/oauth2"
)
@ -31,6 +32,7 @@ import (
type AuthService struct {
userRepository domain.UserRepository
jwtSecret []byte
logs []logging.Logger
}
// 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
// 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{
userRepository: userRepository,
jwtSecret: jwtSecret,
logs: logs,
}
}
@ -124,3 +127,53 @@ func generateJwt(userId int, email string, jwtSecret []byte) (string, error) {
return tokenString, nil
}
// import (
// "bytes"
// "time"
//
// "github.com/gin-gonic/gin"
// "github.com/google/uuid"
// "golang.org/x/exp/slog" // or your logging.Logger
// )
//
// // LoggerMiddleware logs HTTP requests with structured fields
// func LoggerMiddleware(logger *slog.Logger) gin.HandlerFunc {
// return func(c *gin.Context) {
// // Generate request ID for tracing
// reqID := uuid.New().String()
// start := time.Now()
//
// // Capture request body (if enabled)
// var reqBody []byte
// if c.Request.ContentLength > 0 && c.Request.Body != nil {
// reqBody, _ = io.ReadAll(c.Request.Body)
// c.Request.Body.Close()
// c.Request.Body = io.NopCloser(bytes.NewBuffer(reqBody))
// }
//
// // Log request start
// logger.Info("request started",
// slog.String("req_id", reqID),
// slog.String("method", c.Request.Method),
// slog.String("path", c.Request.URL.Path),
// slog.Int("content_length", int(c.Request.ContentLength)),
// slog.String("user_agent", c.Request.UserAgent()),
// )
//
// // Process request
// c.Next()
//
// // Log request completion
// duration := time.Since(start)
// logger.Info("request completed",
// slog.String("req_id", reqID),
// slog.Int("status", c.Writer.Status()),
// slog.String("method", c.Request.Method),
// slog.String("path", c.Request.URL.Path),
// slog.Duration("duration", duration),
// slog.Int("size", c.Writer.Size()),
// slog.Any("req_body", string(reqBody)), // truncate if too big
// )
// }
// }

View File

@ -122,6 +122,48 @@ func (s *EngagementService) UserShareRecipe(userId, recipeId int) (domain.Engage
return s.engagementRepository.AddUserEntityEngagement(userId, recipeId, message, domain.EngagementShared)
}
// UserCreateRecipe requires a user ID and a recipe ID to create an engagement record in the database.
// A message will be generated using the recipe data and then used to add a make engagement to the
// database.
func (s *EngagementService) UserCreateRecipe(userId, recipeId int) (domain.Engagement, error) {
recipe, err := s.recipeRepository.GetRecipe(recipeId, &userId)
if err != nil {
return domain.Engagement{}, err
}
message := fmt.Sprintf("Created \"%s\"", recipe.Title)
return s.engagementRepository.AddUserEntityEngagement(userId, recipeId, message, domain.EngagementCreated)
}
// UserDeleteRecipe requires a user ID and a recipe ID to create an engagement record in the database.
// A message will be generated using the recipe data and then used to add a make engagement to the
// database.
func (s *EngagementService) UserDeleteRecipe(userId, recipeId int) (domain.Engagement, error) {
recipe, err := s.recipeRepository.GetRecipe(recipeId, &userId)
if err != nil {
return domain.Engagement{}, err
}
message := fmt.Sprintf("Deleted \"%s\"", recipe.Title)
return s.engagementRepository.AddUserEntityEngagement(userId, recipeId, message, domain.EngagementDeleted)
}
// UserEditRecipe requires a user ID and a recipe ID to create an engagement record in the database.
// A message will be generated using the recipe data and then used to add a make engagement to the
// database.
func (s *EngagementService) UserEditRecipe(userId, recipeId int) (domain.Engagement, error) {
recipe, err := s.recipeRepository.GetRecipe(recipeId, &userId)
if err != nil {
return domain.Engagement{}, err
}
message := fmt.Sprintf("Edited \"%s\"", recipe.Title)
return s.engagementRepository.AddUserEntityEngagement(userId, recipeId, message, domain.EngagementEdited)
}
// GetUserEngagement returns a list of the users most recent engagement entries. The number of records
// is determined by the limit passed into this function. The results are sorted, newest-to-oldest.
func (s *EngagementService) GetUserEngagement(userId, limit int) ([]domain.Engagement, error) {

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

View File

@ -7,5 +7,8 @@ type EngagementService interface {
UserFavoriteRecipe(userId, recipeId int) (Engagement, error)
UserMakeRecipe(userId, recipeId int) (Engagement, error)
UserShareRecipe(userId, recipeId int) (Engagement, error)
UserCreateRecipe(userId, recipeId int) (Engagement, error)
UserDeleteRecipe(userId, recipeId int) (Engagement, error)
UserEditRecipe(userId, recipeId int) (Engagement, error)
GetUserEngagement(userId, limit int) ([]Engagement, error)
}

View File

@ -124,6 +124,7 @@ type Recipe struct {
Created time.Time
Tags []Tag
Favorite bool // Per requesting user
Deleted bool
}
// SearchFilters is a model which represents the required filters to complete a recipe search.
@ -155,7 +156,7 @@ type RecipeTag struct {
Created time.Time
}
// TODO: Comment
// TODO: Document
type CreateRecipeRequest struct {
Title string
Description string
@ -168,3 +169,18 @@ type CreateRecipeRequest struct {
Sections []RecipeIngredientSection
Tags []string
}
// TODO Document
type EditRecipeRequest struct {
Id int
Title string
Description string
Instructions []RecipeInstruction
Serves int
Difficulty int
Duration RecipeDuration
Category RecipeMeal
Ingredients []RecipeIngredient
Sections []RecipeIngredientSection
Tags []string
}

View File

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

View File

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

View File

@ -2,7 +2,6 @@ package domain
import (
"fmt"
"os"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
@ -11,19 +10,21 @@ import (
domainRecipe "github.com/haydenhargreaves/Potion/internal/domain/recipe"
domainUser "github.com/haydenhargreaves/Potion/internal/domain/user"
"github.com/joho/godotenv"
"github.com/kelseyhightower/envconfig"
)
// EnvironmentConfig stores the configuration of the environment. Anything loaded from the .env
// or docker environment will be stored here and can be accessed from the InjectedDependencies
// struct, which this is attached to.
type EnvironmentConfig struct {
GoogleClientId string
GoogleClientSecret string
JwtSecret string
DatabaseUrl string
Environment string
Domain string
FrontendDomain string
GoogleClientId string `envconfig:"GOOGLE_CLIENT_ID" required:"true"`
GoogleClientSecret string `envconfig:"GOOGLE_CLIENT_SECRET" required:"true"`
JwtSecret string `envconfig:"JWT_SECRET" required:"true"`
DatabaseUrl string `envconfig:"DATABASE_URL" required:"true"`
Domain string `envconfig:"DOMAIN" required:"true"`
FrontendDomain string `envconfig:"FRONTEND_DOMAIN" required:"true"`
Environment string `envconfig:"ENVIRONMENT" required:"true"`
LogFilePath string `envconfig:"LOG_FILE_PATH" required:"false"`
}
// 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
}
// LoadEnvironment loads the environment values from either an .env file or docker environment. In
// the event that required fields are not provided, an error will return and the caller should handle
// the missing value or panic. Toggles between 'dev', 'prod', etc are also handled by this method,
// the values can be access assuming they are the proper values based on the provided environment.
// LoadEnvironment loads the environment values from either an .env file or docker environment.
func LoadEnvironment() (*EnvironmentConfig, error) {
err := godotenv.Load(".env")
if err != nil {
fmt.Printf("No .env file found or error loading .env: %v. Relying on system environment variables.", err)
}
// NOTE: Does the error return matter?
godotenv.Load(".env")
env := os.Getenv("ENVIRONMENT")
if env == "" {
return nil, fmt.Errorf("ENVIRONMENT environment variable is required.")
cfg := &EnvironmentConfig{}
if err := envconfig.Process("", cfg); err != nil {
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
}

View File

@ -1,6 +1,6 @@
-- Author: Hayden Hargreaves (hhargreaves2006@gmail.com)
-- 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()
LANGUAGE plpgsql
@ -20,6 +20,9 @@ BEGIN
NOW()
FROM
Engagements e
JOIN Recipes r
ON r.Id = e.Entity
AND r.Deleted = FALSE
WHERE
e.Created >= NOW() - INTERVAL '7 days'
AND e.Entity IS NOT NULL

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/009_create_recipe_of_the_week_table.sql
psql -h "$PSQL_HOST" -U "$PSQL_USERNAME" -d "$PSQL_DATABASE" -f ./internal/infrastructure/database/migrations/010_create_recipe_of_the_week_procedure.sql
psql -h "$PSQL_HOST" -U "$PSQL_USERNAME" -d "$PSQL_DATABASE" -f ./internal/infrastructure/database/migrations/011_update_engagement_enum.sql
psql -h "$PSQL_HOST" -U "$PSQL_USERNAME" -d "$PSQL_DATABASE" -f ./internal/infrastructure/database/migrations/012_update_recipes_table_deleted_column.sql
psql -h "$PSQL_HOST" -U "$PSQL_USERNAME" -d "$PSQL_DATABASE" -f ./internal/infrastructure/database/migrations/013_update_recipes_allow_large_servings.sql
psql -h "$PSQL_HOST" -U "$PSQL_USERNAME" -d "$PSQL_DATABASE" -f ./internal/infrastructure/database/migrations/014_create_logs_table.sql
psql -h "$PSQL_HOST" -U "$PSQL_USERNAME" -d "$PSQL_DATABASE" -f ./internal/infrastructure/database/migrations/015_update_engagement_enum.sql

View File

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

View File

@ -3,16 +3,18 @@ package repository
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
sq "github.com/Masterminds/squirrel"
domain "github.com/haydenhargreaves/Potion/internal/domain/recipe"
"github.com/jmoiron/sqlx"
"github.com/lib/pq"
)
type RecipeRepository struct {
db *sql.DB
db *sqlx.DB
}
// Compile-time check to ensure the RecipeRepository implements domain.RecipeRepository
@ -20,7 +22,7 @@ var _ domain.RecipeRepository = (*RecipeRepository)(nil)
// NewRecipeRepository creates a user repository object which is used by the user service to access
// the database. Any recipe related database operations will take place in this repository.
func NewRecipeRepository(db *sql.DB) domain.RecipeRepository {
func NewRecipeRepository(db *sqlx.DB) domain.RecipeRepository {
return &RecipeRepository{db: db}
}
@ -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
// directly and the new fields (ID, created) can be accessed upon success.
func (r *RecipeRepository) CreateRecipe(recipe *domain.Recipe) error {
tx, err := r.db.Begin()
// Convert data into a readable format
durationJSON, err := json.Marshal(recipe.Duration)
if err != nil {
tx.Rollback()
return err
}
query := `INSERT INTO recipes (
title, description, instructions, serves, difficulty,
duration, category, ingredients, userid, modified, created
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11
) RETURNING id;`
ingredientsStore := domain.RecipeIngredientStore{
Sections: recipe.Sections,
Ingredients: recipe.Ingredients,
}
// NOTE: Data steps
// cast duration to JSON
// convert ingredients to store type
// cast store type to JSON
// extract string instructions from type
// cast category to string
// use nil for the modified time
ingredientsJSON, err := json.Marshal(ingredientsStore)
if err != nil {
return err
}
instructions := make([]string, len(recipe.Instructions))
for i, instruction := range recipe.Instructions {
instructions[i] = instruction.Content
}
psql := sq.StatementBuilder.PlaceholderFormat(sq.Dollar)
query := psql.
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)
if err != nil {
@ -72,32 +129,76 @@ func (r *RecipeRepository) CreateRecipe(recipe *domain.Recipe) error {
instructions[i] = instruction.Content
}
var id int
if err = tx.QueryRow(
query,
recipe.Title,
recipe.Description,
pq.Array(instructions),
recipe.Serves,
recipe.Difficulty,
durationJSON,
string(recipe.Category),
ingredientsJSON,
recipe.UserId,
nil,
recipe.Created,
).Scan(&id); err != nil {
tx.Rollback()
return err
psql := sq.StatementBuilder.PlaceholderFormat(sq.Dollar)
query := psql.
Update("recipes").
Set("title", recipe.Title).
Set("description", recipe.Description).
Set("instructions", pq.Array(instructions)).
Set("serves", recipe.Serves).
Set("difficulty", recipe.Difficulty).
Set("duration", durationJSON).
Set("category", string(recipe.Category)).
Set("ingredients", ingredientsJSON).
Set("modified", time.Now().UTC()).
Where(sq.Eq{
"id": recipe.Id,
"userid": userId,
})
_sql, args, err := query.ToSql()
if err != nil {
return fmt.Errorf("Failed to construct query: %w", err)
}
if err := tx.Commit(); err != nil {
tx.Rollback()
return err
result, err := r.db.Exec(_sql, args...)
if err != nil {
return fmt.Errorf("Failed to update recipe: %w", err)
}
// Set the new ID
recipe.Id = id
if rows, err := result.RowsAffected(); err != nil {
return err
} else if rows != 1 {
return fmt.Errorf("Modified an unexpected number of rows. Expected 1, modified %d.", rows)
}
return nil
}
// DeleteRecipe deletes a recipe in the database. This is done by setting the deleted field to true.
// This will create a "soft delete" effect. This function does not validate that the user is the owner,
// so the caller should validate the owner. If any errors occur, they will be returned to the caller.
func (r *RecipeRepository) DeleteRecipe(recipeId, userId int) error {
psql := sq.StatementBuilder.PlaceholderFormat(sq.Dollar)
query := psql.
Update("recipes").
Set("deleted", true).
Set("modified", time.Now().UTC()).
Where(sq.Eq{
"id": recipeId,
"userid": userId,
"deleted": false,
})
sql, args, err := query.ToSql()
if err != nil {
return fmt.Errorf("Failed to build delete query: %w", err)
}
result, err := r.db.Exec(sql, args...)
if err != nil {
return fmt.Errorf("Failed to delete recipe: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("Failed to get rows affects: %w", err)
}
if rows != 1 {
return fmt.Errorf("Incorrect number of rows modified. Expected 1, received %d.", rows)
}
return nil
}
@ -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
// for added safety. The repository will not check for a nil result, instead the service will. Callers
// are responsible for protecting against double nil results. Any errors will be bubbled to the caller.
//
// This function will only return recipes that are not deleted. Any recipes marked deleted will be ignored
// and the standard "not-found" error will be returned.
func (r *RecipeRepository) GetRecipe(id int, userId *int) (*domain.Recipe, error) {
query := ` SELECT
query := `SELECT
id, title, description, instructions, serves, difficulty, duration, category, ingredients,
userid, modified, created
userid, modified, created, deleted
FROM recipes
WHERE id = $1
WHERE id = $1 AND deleted = false;
`
var durationBytes []byte
@ -122,7 +226,6 @@ func (r *RecipeRepository) GetRecipe(id int, userId *int) (*domain.Recipe, error
&recipe.Id,
&recipe.Title,
&recipe.Description,
// pq.Array(&instructions),
&instructions,
&recipe.Serves,
&recipe.Difficulty,
@ -132,8 +235,12 @@ func (r *RecipeRepository) GetRecipe(id int, userId *int) (*domain.Recipe, error
&recipe.UserId,
&recipe.Modified,
&recipe.Created,
&recipe.Deleted,
); err != nil {
return nil, fmt.Errorf("Failed to location recipe in database: %s", err.Error())
if err == sql.ErrNoRows {
return nil, err
}
return nil, fmt.Errorf("Failed to location recipe (id: %d) in database: %s", id, err.Error())
}
// Parse duration
@ -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
// will. Callers are responsible for protecting against double nil results. Any errors will be bubbled
// to the caller.
//
// This function calls a function that only returns recipes that are not deleted. Any recipes marked
// deleted will be ignored and the standard "not-found" error will be returned.
func (r *RecipeRepository) GetRecipes(ids []int, userId *int) ([]domain.Recipe, error) {
var recipes []domain.Recipe
for _, id := range ids {
recipe, err := r.GetRecipe(id, userId)
if err != nil {
if err != nil && err != sql.ErrNoRows {
return nil, err
}
@ -220,182 +330,168 @@ func isBitActive(bits, pos int) bool {
//
// TODO: Pagination is required, to provide infinite scroll.
//
// TODO: This does not work in the current build, the DB does not return valid values.
//
// 12/28/25: This function has changed, now longer returns the recipes, but their IDs for fetching
// elsewhere.
//
// 2/3/26: Refactored this large function to use Squirrel for simpler generation. Reduced line count by 50,
// but this is still insane. We need to clean this up.
//
// This function will only return recipes that are not deleted. Any recipes marked deleted will be ignored
// and the standard "not-found" error will be returned.
func (r *RecipeRepository) SearchRecipes(filters domain.SearchFilters, userId *int, favorites bool) ([]int, error) {
// Compute meals type filters (there are 7 bits)
var mealConditions []string
// Begin creating the query
psql := sq.StatementBuilder.PlaceholderFormat(sq.Dollar)
query := psql.Select("r.id").From("recipes r")
// Only select fields where the recipe ID can be found in the favorites table (mapped to user ID)
if favorites && userId != nil {
query = query.
Join("favorites f ON f.recipeId = r.id").
Where(sq.Eq{"f.userid": *userId})
}
// Compute and add meal type filters (7 bit options)
var mealCategories []string
for i := range 7 {
if isBitActive(filters.MealType, i) {
mealConditions = append(mealConditions, fmt.Sprintf("category = '%s'", domain.ParseMeal(i)))
mealCategories = append(mealCategories, string(domain.ParseMeal(i)))
}
}
// Compute time filters (there are 5 bits)
var timeConditions []string
if len(mealCategories) > 0 {
query = query.Where(sq.Eq{"category": mealCategories})
}
// Compute and add time filters (5 bit options)
var timeOr sq.Or
for i := range 5 {
var cond string
if isBitActive(filters.Time, i) {
switch i {
case 0:
cond = "(duration->>'total')::int < 15"
timeOr = append(timeOr, sq.Lt{"(duration->>'total')::int": 15})
case 1:
cond = "(duration->>'total')::int BETWEEN 15 AND 30"
timeOr = append(timeOr, sq.Expr("(duration->>'total')::int BETWEEN 15 AND 30"))
case 2:
cond = "(duration->>'total')::int BETWEEN 30 AND 60"
timeOr = append(timeOr, sq.Expr("(duration->>'total')::int BETWEEN 30 AND 60"))
case 3:
cond = "(duration->>'total')::int BETWEEN 60 AND 120"
timeOr = append(timeOr, sq.Expr("(duration->>'total')::int BETWEEN 60 AND 120"))
case 4:
cond = "(duration->>'total')::int > 120"
timeOr = append(timeOr, sq.Gt{"(duration->>'total')::int": 120})
}
timeConditions = append(timeConditions, cond)
}
}
// Compute difficulty filters (there are 5 bits)
var difficultyConditions []string
if len(timeOr) > 0 {
query = query.Where(timeOr)
}
// Compute and add difficulty filters (5 bit options)
var difficulties []int
for i := range 5 {
if isBitActive(filters.Difficulty, i) {
cond := fmt.Sprintf("difficulty = '%d'", i+1)
difficultyConditions = append(difficultyConditions, cond)
difficulties = append(difficulties, i+1)
}
}
// Compute serving size filters (there are 5 bits)
var servingConditions []string
if len(difficulties) > 0 {
query = query.Where(sq.Eq{"difficulty": difficulties})
}
// Compute and add serving size filters (5 bit options)
var servingOr sq.Or
for i := range 5 {
var cond string
if isBitActive(filters.ServingSize, i) {
switch i {
case 0:
cond = "serves BETWEEN 1 AND 2"
servingOr = append(servingOr, sq.Expr("serves BETWEEN 1 AND 2"))
case 1:
cond = "serves BETWEEN 2 AND 4"
servingOr = append(servingOr, sq.Expr("serves BETWEEN 2 AND 4"))
case 2:
cond = "serves BETWEEN 4 AND 6"
servingOr = append(servingOr, sq.Expr("serves BETWEEN 4 AND 6"))
case 3:
cond = "serves BETWEEN 6 AND 8"
servingOr = append(servingOr, sq.Expr("serves BETWEEN 6 AND 8"))
case 4:
cond = "serves > 8"
servingOr = append(servingOr, sq.Gt{"serves": 8})
}
servingConditions = append(servingConditions, cond)
}
}
// Merge condition strings
mealString := fmt.Sprintf("(%s)", strings.Join(mealConditions, " OR "))
timeString := fmt.Sprintf("(%s)", strings.Join(timeConditions, " OR "))
difficultyString := fmt.Sprintf("(%s)", strings.Join(difficultyConditions, " OR "))
servingString := fmt.Sprintf("(%s)", strings.Join(servingConditions, " OR "))
// Combine condition strings
var conditions []string
if len(mealConditions) > 0 {
conditions = append(conditions, mealString)
}
if len(timeConditions) > 0 {
conditions = append(conditions, timeString)
}
if len(difficultyConditions) > 0 {
conditions = append(conditions, difficultyString)
}
if len(servingConditions) > 0 {
conditions = append(conditions, servingString)
if len(servingOr) > 0 {
query = query.Where(servingOr)
}
// Define columns to select. More fields can be added if the full text search is required
columns := []string{
"r.id",
}
// TODO: Need to add these to the query
// FROM ... JOIN favorites f ON f.recipeId = r.id
// WHERE ... AND f.userId = 3
// Create search vector query
var orderBy string = ""
// Handle search with full-text search and ILIKE fallback
if filters.Search != "" {
spl := strings.Split(filters.Search, " ")
var cleaned []string
// Use a string replacer, each word in the query will be passed through this
// Sanitize search terms
replacer := strings.NewReplacer(
"'", "",
"-", "",
"&", "",
"|", "",
"!", "",
":", "",
"(", "",
")", "",
)
for i := range len(spl) {
q := strings.TrimSpace(replacer.Replace(spl[i]))
for _, term := range spl {
q := strings.TrimSpace(replacer.Replace(term))
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(
conditions,
fmt.Sprintf("r.search_vector @@ to_tsquery('english', '%s')", vector_query),
)
// Build search condition as raw SQL expression
// We'll use sq.Expr for the entire OR clause
var searchConditions []string
var searchArgs []interface{}
template := `
ORDER BY
ts_rank(r.search_vector, to_tsquery('english', '%s')) DESC,
ts_rank_cd(r.search_vector, to_tsquery('english', '%s')) DESC
`
orderBy = fmt.Sprintf(template, vector_query, vector_query)
}
// Full-text search
searchConditions = append(searchConditions, "r.search_vector @@ to_tsquery('english', ?)")
searchArgs = append(searchArgs, vectorQuery)
// Generate the query
var query string
if favorites && userId != nil {
query = fmt.Sprintf(
"SELECT %s FROM recipes r JOIN favorites f ON f.recipeId = r.id",
strings.Join(columns, ","),
)
// ILIKE fallback for substring matching
for _, term := range spl {
cleanTerm := strings.TrimSpace(replacer.Replace(term))
if cleanTerm != "" {
searchConditions = append(searchConditions, "r.title ILIKE ?")
searchArgs = append(searchArgs, "%"+cleanTerm+"%")
// Add new favorite condition to the conditions list
conditions = append(conditions, fmt.Sprintf("f.userid = %d", *userId))
} else {
query = fmt.Sprintf("SELECT %s FROM recipes r", strings.Join(columns, ","))
}
searchConditions = append(searchConditions, "r.description ILIKE ?")
searchArgs = append(searchArgs, "%"+cleanTerm+"%")
}
}
// Convert and append conditions if provided
if len(conditions) > 0 {
conditionsString := fmt.Sprintf("WHERE %s", strings.Join(conditions, " AND "))
query = fmt.Sprintf("%s %s", query, conditionsString)
}
// Combine all conditions with OR
searchExpr := fmt.Sprintf("(%s)", strings.Join(searchConditions, " OR "))
query = query.Where(sq.Expr(searchExpr, searchArgs...))
// Append sorting order if exists
if len(orderBy) > 0 {
query = fmt.Sprintf("%s %s", query, orderBy)
}
// Finish it off with a colon!
query += ";"
// Execute the query
rows, err := r.db.Query(query)
if err != nil {
return []int{}, fmt.Errorf("failed to query recipes: %w", err)
}
defer rows.Close()
var ids []int
for rows.Next() {
var id int
if err := rows.Scan(&id); err != nil {
return []int{}, fmt.Errorf("failed to extract ID: %s\n", err.Error())
// Add ordering for search results
query = query.
OrderBy(fmt.Sprintf("CASE WHEN r.search_vector @@ to_tsquery('english', '%s') THEN 1 ELSE 2 END", vectorQuery)).
OrderBy(fmt.Sprintf("ts_rank(r.search_vector, to_tsquery('english', '%s')) DESC", vectorQuery)).
OrderBy(fmt.Sprintf("ts_rank_cd(r.search_vector, to_tsquery('english', '%s')) DESC", vectorQuery))
}
}
ids = append(ids, id)
// Exclude deleted recipes
query = query.Where(sq.Eq{"deleted": false})
sql, args, err := query.ToSql()
if err != nil {
return nil, fmt.Errorf("Failed to build query: %w", err)
}
// Execute query using SQLX
var ids []int
if err = r.db.Select(&ids, sql, args...); err != nil {
return nil, fmt.Errorf("Failed to query recipes: %w", err)
}
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 {
tx, err := r.db.Begin()
if err != nil {
tx.Rollback()
return err
}
@ -460,33 +555,110 @@ func (r *RecipeRepository) CreateRecipeTags(recipe domain.Recipe, tags []string)
return nil
}
// UpdateRecipeTags replaces all existing tags for a recipe with a new list of tags.
// It removes all current tag associations, creates any new tags that don't exist,
// and creates new associations for the provided tags. The recipe object must contain
// a valid ID. Any errors will be bubbled to the caller.
func (r *RecipeRepository) UpdateRecipeTags(recipe domain.Recipe, tags []string) error {
tx, err := r.db.Begin()
if err != nil {
return err
}
defer tx.Rollback() // Rollback if we don't commit
if recipe.Id <= 0 {
return fmt.Errorf("[ERROR] Recipe must have a valid ID")
}
// Step 1: Delete all existing tag associations for this recipe
deleteQuery := `DELETE FROM RecipeTags WHERE RecipeId = $1;`
if _, err := tx.Exec(deleteQuery, recipe.Id); err != nil {
return fmt.Errorf("[ERROR] Failed to delete existing recipe tags: %w", err)
}
// Step 2: Normalize the tag names (lower case with trimmed space)
normalized := make(map[string]struct{}) // Use map to disallow duplicates
for _, tag := range tags {
trimmed := strings.ToLower(strings.TrimSpace(tag))
if trimmed != "" {
normalized[trimmed] = struct{}{}
}
}
// If no tags provided, we're done (all tags removed)
if len(normalized) == 0 {
if err := tx.Commit(); err != nil {
return err
}
return nil
}
// Step 3: Insert the tags into the DB and return their IDs into the tag ID list
var tagIds []int
for tag := range normalized {
var tagId int
query := `
INSERT INTO tags (name) VALUES ($1)
ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name
RETURNING id;
`
err := tx.QueryRow(query, tag).Scan(&tagId)
if err != nil {
return fmt.Errorf("[ERROR] Failed to retrieve or create tag: %w", err)
}
tagIds = append(tagIds, tagId)
}
// Step 4: Insert the new tag associations
// Use a single prepared statement for all inserts
stmt, err := tx.Prepare("INSERT INTO RecipeTags (RecipeId, TagId) VALUES ($1, $2);")
if err != nil {
return fmt.Errorf("[ERROR] Failed to create statement for recipe tag mapping: %w", err)
}
defer stmt.Close()
for _, id := range tagIds {
if _, err := stmt.Exec(recipe.Id, id); err != nil {
return fmt.Errorf("[ERROR] Failed to insert tag-recipe mapping: %w", err)
}
}
// Commit the transaction
if err := tx.Commit(); err != nil {
return err
}
return nil
}
// GetUserRecipes gets a list of a users owned recipes. This function does not ensure the user is
// authenticated or exists. If nothing is found, a blank slice will be returned. The resulting list
// is sorted by the created dates, newest first. Any errors will be bubbled to the caller.
//
// 12/28/25: This now returns just the IDs, the service can handle fetching them.
func (r *RecipeRepository) GetUserRecipesIds(user_id int) ([]int, error) {
query := `
SELECT id
FROM recipes
WHERE userid = $1
ORDER BY created DESC;
`
//
// This function will only return recipes that are not deleted. Any recipes marked deleted will be ignored
// and the standard "not-found" error will be returned.
func (r *RecipeRepository) GetUserRecipesIds(userId int) ([]int, error) {
psql := sq.StatementBuilder.PlaceholderFormat(sq.Dollar)
rows, err := r.db.Query(query, user_id)
query := psql.
Select("id").
From("recipes").
Where(sq.Eq{
"userid": userId,
"deleted": false,
}).
OrderBy("created DESC")
_sql, args, err := query.ToSql()
if err != nil {
return nil, fmt.Errorf("Failed to query DB for user recipes. %s\n", err.Error())
return []int{}, fmt.Errorf("Failed to construct SQL query: %w", err)
}
defer rows.Close()
var ids []int
for rows.Next() {
var r_id int
if err := rows.Scan(&r_id); err != nil {
return []int{}, fmt.Errorf("Failed to scan ID from db. %s\n", err.Error())
}
ids = append(ids, r_id)
if err := r.db.Select(&ids, _sql, args...); err != nil {
return []int{}, fmt.Errorf("Failed to get user recipes: %w", err)
}
return ids, nil
@ -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.
//
// 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 := `
SELECT r.id
FROM favorites f
JOIN recipes r ON r.id = f.recipeid
WHERE f.userid = $1
ORDER BY f.created DESC;
`
rows, err := r.db.Query(query, id)
//
// This function will only return recipes that are not deleted. Any recipes marked deleted will be ignored
// and the standard "not-found" error will be returned.
func (r *RecipeRepository) GetUserFavoriteRecipesIds(userId int) ([]int, error) {
psql := sq.StatementBuilder.PlaceholderFormat(sq.Dollar)
query := psql.
Select("r.id").
From("favorites f").
Join("recipes r on r.id = f.recipeid").
Where(sq.Eq{
"f.userid": userId,
"deleted": false,
}).
OrderBy("f.created DESC")
_sql, args, err := query.ToSql()
if err != nil {
return nil, fmt.Errorf("Failed to query DB for user recipes. %s\n", err.Error())
return []int{}, fmt.Errorf("Failed to construct SQL query: %w", err)
}
defer rows.Close()
fmt.Println(_sql)
var ids []int
for rows.Next() {
var r_id int
if err := rows.Scan(&r_id); err != nil {
return []int{}, fmt.Errorf("Failed to scan ID from db. %s\n", err.Error())
}
ids = append(ids, r_id)
if err := r.db.Select(&ids, _sql, args...); err != nil {
return []int{}, fmt.Errorf("Failed to get users' favorite recipes: %w", err)
}
return ids, nil
@ -569,15 +745,24 @@ func (r *RecipeRepository) GetRecipeFavorite(recipe *domain.Recipe, userId int)
return nil
}
query := `
SELECT COUNT(*)
FROM favorites
WHERE recipeid = $1 AND userid = $2;
`
psql := sq.StatementBuilder.PlaceholderFormat(sq.Dollar)
query := psql.
Select("COUNT(*)").
From("favorites").
Where(sq.Eq{
"recipeid": recipe.Id,
"userid": userId,
})
_sql, args, err := query.ToSql()
if err != nil {
return fmt.Errorf("Failed to construct SQL query: %w", err)
}
var count int
if err := r.db.QueryRow(query, recipe.Id, userId).Scan(&count); err != nil {
return fmt.Errorf("Failed to get recipe favorite. %s", err.Error())
if err := r.db.Get(&count, _sql, args...); err != nil {
return fmt.Errorf("Failed to get recipe favorite status: %w", err)
}
recipe.Favorite = count > 0
@ -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
// the caller. All that is returned is the recipe ID, that way the caller can handle the fetching.
func (r *RecipeRepository) GetRecipeOfTheWeekId(userId *int) (*int, error) {
query := `
SELECT
r.id
FROM recipes r
JOIN recipeoftheweek rw ON rw.recipeid = r.id
ORDER BY rw.created DESC
LIMIT 1;
`
psql := sq.StatementBuilder.PlaceholderFormat(sq.Dollar)
var id int
if err := r.db.QueryRow(query).Scan(&id); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("Failed to location recipe in database: %s", err.Error())
query := psql.
Select("r.id").
From("recipes r").
Join("recipeoftheweek rw ON rw.recipeid = r.id").
Where(sq.Eq{"r.deleted": false}).
OrderBy("rw.created DESC").
Limit(1)
_sql, args, err := query.ToSql()
if err != nil {
return nil, fmt.Errorf("Failed to build SQL query: %w", err)
}
return &id, nil
var recipeId int
if err := r.db.Get(&recipeId, _sql, args...); err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, fmt.Errorf("Failed to locate recipe in database: %s", err.Error())
}
return &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"
"fmt"
sq "github.com/Masterminds/squirrel"
domain "github.com/haydenhargreaves/Potion/internal/domain/user"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
)
type UserRepository struct {
db *sql.DB
db *sqlx.DB
}
// Compile-time check to ensure the UserRepository implements domain.UserRepository
@ -17,7 +19,7 @@ var _ domain.UserRepository = (*UserRepository)(nil)
// NewUserRepository creates a user repository object which is used by the user service to access
// the database. Any user related database operations will take place in this repository.
func NewUserRepository(db *sql.DB) domain.UserRepository {
func NewUserRepository(db *sqlx.DB) domain.UserRepository {
return &UserRepository{db: db}
}
@ -30,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
// it.
func (r *UserRepository) CreateGoogleUser(googleUserInfo *domain.GoogleUserInfo, googleRefreshToken string) (domain.User, error) {
tx, err := r.db.Begin()
if err != nil {
tx.Rollback()
return domain.User{}, err
}
if googleUserInfo == nil {
return domain.User{}, fmt.Errorf("Google user info provided was nil")
}
var user domain.User
query := `INSERT INTO users
(GoogleId, Name, Email, ImageUrl, GoogleRefreshToken)
VALUES ($1, $2, $3, $4, $5) RETURNING *;`
psql := sq.StatementBuilder.PlaceholderFormat(sq.Dollar)
if err := tx.QueryRow(
query,
googleUserInfo.Id,
googleUserInfo.Name,
googleUserInfo.Email,
googleUserInfo.Picture,
googleRefreshToken,
).Scan(
&user.Id,
&user.GoogleId,
&user.Name,
&user.Email,
&user.ImageUrl,
&user.GoogleRefreshToken,
&user.Created,
); err != nil {
tx.Rollback()
return domain.User{}, err
query := psql.
Insert("users").
Columns(
"googleid",
"name",
"email",
"imageurl",
"googlerefreshtoken").
Values(
googleUserInfo.Id,
googleUserInfo.Name,
googleUserInfo.Email,
googleUserInfo.Picture,
googleRefreshToken,
).
Suffix("RETURNING *")
_sql, args, err := query.ToSql()
if err != nil {
return domain.User{}, fmt.Errorf("Failed to construct sql query: %w", err)
}
if err := tx.Commit(); err != nil {
tx.Rollback()
return domain.User{}, err
var user domain.User
if err := r.db.Get(&user, _sql, args...); err != nil {
return domain.User{}, fmt.Errorf("Failed to create user: %w", err)
}
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
// no user is found, this function will return a null pointer but not an error.
func (r *UserRepository) GetGoogleUser(googleId string) (*domain.User, error) {
var user domain.User
query := `SELECT * FROM users WHERE GoogleId = $1`
psql := sq.StatementBuilder.PlaceholderFormat(sq.Dollar)
if err := r.db.QueryRow(query, googleId).Scan(
&user.Id,
&user.GoogleId,
&user.Name,
&user.Email,
&user.ImageUrl,
&user.GoogleRefreshToken,
&user.Created,
); err != nil {
// If no user was found, don't error, just return
query := psql.
Select("*").
From("users").
Where(sq.Eq{
"GoogleId": googleId,
})
_sql, args, err := query.ToSql()
if err != nil {
return nil, fmt.Errorf("Failed to construct sql query: %w", err)
}
var user domain.User
if err := r.db.Get(&user, _sql, args...); err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, err
return nil, fmt.Errorf("Failed to get Google user: %w", err)
}
return &user, nil
@ -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
// to the caller.
func (r *UserRepository) GetUser(id int) (*domain.User, error) {
query := "SELECT * FROM users WHERE id = $1"
psql := sq.StatementBuilder.PlaceholderFormat(sq.Dollar)
query := psql.
Select("*").
From("users").
Where(sq.Eq{"id": id})
_sql, args, err := query.ToSql()
if err != nil {
return nil, fmt.Errorf("Failed to construct sql query: %w", err)
}
var user domain.User
if err := r.db.QueryRow(query, id).Scan(
&user.Id,
&user.GoogleId,
&user.Name,
&user.Email,
&user.ImageUrl,
&user.GoogleRefreshToken,
&user.Created,
); err != nil {
if err := r.db.Get(&user, _sql, args...); err != nil {
// If no user was found, don't error, just return
if err == sql.ErrNoRows {
return nil, nil

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?
.env
.vite

View File

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

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

@ -32,7 +32,7 @@ export default function FavoriteButton({ favorite, id }: FavoriteButtonProps) {
const result = await EngagementFavoriteRecipe(id);
if (isApiError(result)) {
console.error(result.message);
}
}
}
useEffect(() => {
@ -43,7 +43,7 @@ export default function FavoriteButton({ favorite, id }: FavoriteButtonProps) {
return _favorite ? (
<button
className="flex items-center 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()}
>
<svg className="h-6 text-red-500" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">

View File

@ -30,12 +30,12 @@ export default function MadeButton({ id }: MadeButtonProps) {
const result = await EngagementMakeRecipe(id);
if (isApiError(result)) {
console.error(result.message);
}
}
}
return (
<button
className={`flex items-center 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()}
>
<svg

View File

@ -14,7 +14,6 @@ export default function ShareButton({ id }: ShareButtonProps) {
const clickHandler = async () => {
if (clicked) return;
console.log(window.location);
// Copy first, so it feels fast
const url = `${window.location.origin}${ROUTE_CONSTANTS.Recipe(id)}`;
@ -30,7 +29,7 @@ export default function ShareButton({ id }: ShareButtonProps) {
};
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">
<path
fillRule="evenodd"
@ -42,7 +41,7 @@ export default function ShareButton({ id }: ShareButtonProps) {
</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()}
>
<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">
Serves {recipe.Serves}
</p>
<p className="text-sm text-wrap w-80">
<p className="text-sm text-wrap w-80 break-all">
{recipe.Description}
</p>
<div className="flex items-end justify-between">

View File

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

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 {
filters: SearchFilters;
setFilters: (filters: SearchFilters) => void;
resetFilters: () => void;
}
export const FilterContext = createContext<FilterContextType>({
@ -16,4 +17,5 @@ export const FilterContext = createContext<FilterContextType>({
Favorites: false,
},
setFilters: () => { return },
resetFilters: () => { return },
});

View File

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

View File

@ -46,7 +46,7 @@ export function useIngredients() {
const ingredientChange = (id: string, name: "Amount" | "Unit" | "Name", value: string) => {
setIngredients(prev =>
prev.map(ing =>
ing.Id === id
ing.Id === id
? { ...ing, [name]: name === "Amount" ? Number(value) : value }
: ing
)
@ -72,6 +72,7 @@ export function useIngredients() {
return {
sections,
ingredients,
setIngredients,
setSections,
sectionChange,
ingredientChange,

View File

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

View File

@ -9,12 +9,12 @@ export default function WebLayout() {
<div className="bg-gray-100 min-h-screen">
<Navigation />
<div className="w-full flex justify-center">
<div className="mx-2 md:mx-0 w-full md:w-1/2 md:pt-14 min-h-screen h-fit border-l border-r border-gray-300 bg-white">
<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 />
</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 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 ValidationErrorList from "../components/forms/ValidationErrorList";
import IngredientSection from "../components/forms/IngredientSection";
@ -13,10 +13,10 @@ import RecipeCreateFormWrapper from "../components/inputs/RecipeCreateFormWrappe
import RecipeCreateFormTagsInputs from "../components/inputs/RecipeCreateFormTagsInput";
import { useIngredients } from "../hooks/useIngredients";
import { validateCreateRecipeForm } from "../hooks/validation";
import { CreateRecipe } from "../services/RecipeService";
import type { CreateRecipeRequest } from "../types/api/recipe";
import { isApiError } from "../types/api/error";
import { useNavigate } from "react-router-dom";
import { CreateRecipe, EditRecipe, GetRecipe, IsRecipeOwner } from "../services/RecipeService";
import type { CreateRecipeRequest, EditRecipeRequest } from "../types/api/recipe";
import { isApiError, type ApiError } from "../types/api/error";
import { useNavigate, useSearchParams } from "react-router-dom";
import ROUTE_CONSTANTS from "../types/routes";
// TODO: Move these
@ -127,9 +127,8 @@ export default function Create() {
// Functions
const createRecipe = async (): Promise<void> => {
console.log({ title, description, tags, prepTime, cookTime, servingSize, category, difficulty, sections, ingredients, instructions });
// Exit if not valid recipe meal
// This is a REQUIRED typescript check.
if (!isRecipeMeal(category)) {
console.error("[ERROR] Recipe meal is invalid.");
return;
@ -162,10 +161,53 @@ export default function Create() {
await navigate(ROUTE_CONSTANTS.Recipe(response.Id));
};
const editRecipe = async (): Promise<void> => {
const recipeId = Number(editingId);
if (!recipeId) {
console.error("[ERROR] Invalid reicpe ID");
return;
}
// Exit if not valid recipe meal
// This is a REQUIRED typescript check.
if (!isRecipeMeal(category)) {
console.error("[ERROR] Recipe meal is invalid.");
return;
}
const recipe: EditRecipeRequest = {
Id: recipeId,
Title: title,
Description: description,
Instructions: instructions,
Serves: Number(servingSize),
Difficulty: Number(difficulty),
Duration: {
Prep: Number(prepTime),
Cook: Number(cookTime),
Total: Number(prepTime) + Number(cookTime)
},
Category: category,
Ingredients: ingredients,
Sections: sections,
Tags: tags,
};
const response = await EditRecipe(recipe);
if (isApiError(response)) {
console.error(response);
return;
}
// TODO: Success toast!
await navigate(ROUTE_CONSTANTS.Recipe(response.Id));
}
// Import ingredients
const {
sections,
ingredients,
setIngredients,
setSections,
sectionChange,
ingredientChange,
@ -246,7 +288,11 @@ export default function Create() {
return;
}
void createRecipe();
if (editingId) {
void editRecipe();
} else {
void createRecipe();
}
}
@ -269,26 +315,115 @@ export default function Create() {
setIsFormValid(bools_valid && ingredients_valid && instructions_valid);
}, [validation, dirty]);
useEffect(() => {
console.debug("@validation", validation);
}, [validation]);
// EDITING IMPLEMENTATION
const [searchParams] = useSearchParams();
const editingId = searchParams.get("edit");
// Functions
const getIsAuthor = async (recipeId: number) => {
if (!recipeId) return;
const response = await IsRecipeOwner(recipeId);
if (isApiError(response)) {
console.error(response.message);
return;
}
return response;
}
const setForm = (recipe: Recipe) => {
setTitle(recipe.Title);
setDescription(recipe.Description);
setTags(recipe.Tags.map(tag => tag.Name));
setPrepTime(recipe.Duration.Prep.toString());
setCookTime(recipe.Duration.Cook.toString());
setServingSize(recipe.Serves.toString());
setCategory(recipe.Category);
setDifficulty(recipe.Difficulty.toString());
// Generate IDs for instructions and store them
const instructionsWithIds: RecipeInstruction[] = recipe.Instructions.map(ins => ({
Id: crypto.randomUUID(),
Content: ins.Content
}));
setInstructions(instructionsWithIds);
setSections(recipe.Sections);
// Manually set the local state, not ideal but it works
setIngredients(recipe.Ingredients.sort((a, b) => a.SectionId.localeCompare(b.SectionId)));
const ingredientsDirty: Record<string, boolean> = {};
recipe.Ingredients.forEach(ing => {
ingredientsDirty[ing.Id] = true;
});
const instructionsDirty: Record<string, boolean> = {};
instructionsWithIds.forEach(ins => {
instructionsDirty[ins.Id] = true;
});
setDirty({
title: true,
description: true,
prepTime: true,
cookTime: true,
servingSize: true,
category: true,
difficulty: true,
ingredients: ingredientsDirty,
instructions: instructionsDirty,
});
};
// Effects
useEffect(() => {
console.debug("@dirty", dirty);
}, [dirty]);
const id = Number(editingId);
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 (
<>
<Banner content="Create Your Masterpiece" />
<Banner content={editingId ? "Edit Your Recipe" : "Create Your Masterpiece"} />
<div className="mx-4 md:mx-16 my-8">
<p className="mb-8">
Welcome to the Recipe Creation Wizard! Simply fill in the details about your culinary creation,
including the recipe's name, a description, and other specifics like its category, duration,
and difficulty. Don't forget to dynamically add all your ingredients and instructions using
the dedicated buttons, and feel free to upload an appealing image. All required fields are
marked with an <span className="text-red-500">*</span>. Once everything looks perfect, just hit the "Create Recipe"
button to
share your masterpiece!
{editingId ? (
<>
Welcome back! Update your recipe by modifying any of the details below, including the recipe's name,
description, category, duration, difficulty, ingredients, and instructions. You can add or remove
ingredients and instruction steps using the dedicated buttons, and update the recipe image if desired.
All required fields are marked with an <span className="text-red-500">*</span>. Once you're happy
with your changes, hit the "Update Recipe" button to save your edits!
</>
) : (
<>
Welcome to the Recipe Creation Wizard! Simply fill in the details about your culinary creation,
including the recipe's name, a description, and other specifics like its category, duration,
and difficulty. Don't forget to dynamically add all your ingredients and instructions using
the dedicated buttons, and feel free to upload an appealing image. All required fields are
marked with an <span className="text-red-500">*</span>. Once everything looks perfect, just
hit the "Create Recipe" button to share your masterpiece!
</>
)}
</p>
<div>
{/* Title Input */}
@ -385,7 +520,7 @@ export default function Create() {
error="Please enter a serving size."
parentClasses="flex-grow w-1/3"
min="1"
max="16"
max="127"
classes={INPUT_CLASSES}
/>
</div>
@ -523,7 +658,7 @@ export default function Create() {
disabled={!isFormValid}
className={`${isFormValid ? "bg-gradient-to-r from-blue-200 to-purple-200 cursor-pointer" : "bg-gray-200 text-gray-500 cursor-not-allowed"} w-full py-2 rounded-lg text-lg shadow-md`}
>
Create Recipe
{editingId ? "Save Changes" : "Create Recipe"}
</button>
</div>
</div>

View File

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

View File

@ -115,8 +115,8 @@ export default function Profile() {
<p className="text-xs md:text-sm">{user?.Email}</p>
</div>
<div className="flex gap-x-4">
<p className="text-xs md:text-sm"><span className="font-bold">{recipes.length}</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">{recipes?.length ?? 0}</span> recipes</p>
<p className="text-xs md:text-sm"><span className="font-bold">{favorites?.length ?? 0}</span> favorites</p>
</div>
</div>
</div>
@ -126,10 +126,10 @@ export default function Profile() {
<section className="p-8">
<h2 className="text-2xl font-semibold text-gray-800">My Recipes</h2>
<ul className="w-full my-2">
{recipes.length <= 4 ? (
recipes.map(recipe => <RecipeListItem key={recipe.Id} recipe={recipe} />)
{recipes?.length <= 4 ? (
recipes?.map(recipe => <RecipeListItem key={recipe.Id} recipe={recipe} />)
) : (
recipes.slice(0, 4).map(recipe => <RecipeListItem key={recipe.Id} recipe={recipe} />)
recipes?.slice(0, 4).map(recipe => <RecipeListItem key={recipe.Id} recipe={recipe} />)
)}
<button onClick={seeAllRecipesHandler} className="w-full">
<li className="w-full border-b border-gray-300 px-2 py-4 even:bg-gray-50 hover:bg-gray-100 hover:text-blue-600 duration-150 text-center">
@ -143,10 +143,10 @@ export default function Profile() {
<section className="p-8">
<h2 className="text-2xl font-semibold text-gray-800">My Favorites</h2>
<ul className="w-full my-2">
{favorites.length <= 4 ? (
favorites.map(recipe => <RecipeListItem key={recipe.Id} recipe={recipe} />)
{favorites?.length <= 4 ? (
favorites?.map(recipe => <RecipeListItem key={recipe.Id} recipe={recipe} />)
) : (
favorites.slice(0, 4).map(recipe => <RecipeListItem key={recipe.Id} recipe={recipe} />)
favorites?.slice(0, 4).map(recipe => <RecipeListItem key={recipe.Id} recipe={recipe} />)
)}
<button onClick={seeAllFavoritesHandler} className="w-full">
<li className="w-full border-b border-gray-300 px-2 py-4 even:bg-gray-50 hover:bg-gray-100 hover:text-blue-600 duration-150 text-center">

View File

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

View File

@ -1,5 +1,5 @@
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 { ApiError } from "../types/api/error";
import type { SearchFilters } from "../types/search";
@ -63,3 +63,45 @@ export async function CreateRecipe(data: CreateRecipeRequest): Promise<Recipe |
return response.data.recipe;
}
export async function EditRecipe(data: EditRecipeRequest): Promise<Recipe | ApiError> {
const response = await axios.put<EditRecipeResponse>(`${BACKEND_URL}/v2/api/recipe/${data.Id}`, data);
if (response.status !== 200 || response.data.recipe === undefined) {
const err: ApiError = {
status: response.data.status,
message: response.data.message
};
return err;
}
return response.data.recipe;
}
export async function DeleteRecipe(id: number): Promise<ApiError | null> {
const response = await axios.delete<DeleteRecipeResponse>(`${BACKEND_URL}/v2/api/recipe/${id}`);
if (response.status !== 200) {
const err: ApiError = {
status: response.data.status,
message: response.data.message
};
return err;
}
return null;
}
export async function IsRecipeOwner(recipeId: number): Promise<boolean | ApiError> {
const response = await axios.get<IsRecipeOwnerResponse>(`${BACKEND_URL}/v2/api/recipe/${recipeId}/is-owner`);
if (response.status !== 200) {
const err: ApiError = {
status: response.data.status,
message: response.data.message
};
return err;
}
return response.data.owner;
}

View File

@ -24,6 +24,12 @@ export interface CreateRecipeResponse {
recipe?: Recipe;
}
export interface EditRecipeResponse {
status: number;
message: string;
recipe?: Recipe;
}
export interface CreateRecipeRequest {
Title: string;
Description: string;
@ -36,3 +42,28 @@ export interface CreateRecipeRequest {
Sections: RecipeIngredientSection[];
Tags: string[];
}
export interface EditRecipeRequest {
Id: number;
Title: string;
Description: string;
Instructions: RecipeInstruction[];
Serves: number;
Difficulty: number;
Duration: RecipeDuration;
Category: RecipeMeal;
Ingredients: RecipeIngredient[];
Sections: RecipeIngredientSection[];
Tags: string[];
}
export interface DeleteRecipeResponse {
status: number;
message: string;
}
export interface IsRecipeOwnerResponse {
owner: boolean;
status: number;
message: string;
}

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 {
Id: number;

View File

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

View File

View File

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

View File

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