diff --git a/backend/go.mod b/backend/go.mod index 1280452..c211bb4 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -5,25 +5,37 @@ go 1.23.5 require ( github.com/danielgtaylor/huma/v2 v2.28.0 github.com/go-chi/chi/v5 v5.2.1 + github.com/go-chi/jwtauth/v5 v5.3.2 github.com/go-ozzo/ozzo-validation/v4 v4.3.0 + github.com/golang-jwt/jwt/v5 v5.2.1 github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.7.2 github.com/joho/godotenv v1.5.1 + github.com/lestrrat-go/jwx/v2 v2.1.3 github.com/pressly/goose/v3 v3.24.1 github.com/spf13/cobra v1.8.1 ) require ( github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect + github.com/goccy/go-json v0.10.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/lestrrat-go/blackmagic v1.0.2 // indirect + github.com/lestrrat-go/httpcc v1.0.1 // indirect + github.com/lestrrat-go/httprc v1.0.6 // indirect + github.com/lestrrat-go/iter v1.0.2 // indirect + github.com/lestrrat-go/option v1.0.1 // indirect github.com/mfridman/interpolate v0.0.2 // indirect + github.com/segmentio/asm v1.2.0 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect github.com/spf13/pflag v1.0.6 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/crypto v0.31.0 // indirect golang.org/x/sync v0.10.0 // indirect + golang.org/x/sys v0.28.0 // indirect golang.org/x/text v0.21.0 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index 8459453..c1e8c76 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -6,12 +6,20 @@ github.com/danielgtaylor/huma/v2 v2.28.0/go.mod h1:67KO0zmYEkR+LVUs8uqrcvf44G1wX github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 h1:rpfIENRNNilwHwZeG5+P150SMrnNEcHYvcCuK6dPZSg= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-chi/jwtauth/v5 v5.3.2 h1:s+ON3ATyyMs3Me0kqyuua6Rwu+2zqIIkL0GCaMarwvs= +github.com/go-chi/jwtauth/v5 v5.3.2/go.mod h1:O4QvPRuZLZghl9WvfVaON+ARfGzpD2PBX/QY5vUz7aQ= github.com/go-ozzo/ozzo-validation/v4 v4.3.0 h1:byhDUpfEwjsVQb1vBunvIjh2BHQ9ead57VkAEY4V+Es= github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRiTzuqKbvfrL2RxCj6Ew= +github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= +github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 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/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= @@ -28,6 +36,18 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k= +github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU= +github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= +github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= +github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k= +github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo= +github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= +github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= +github.com/lestrrat-go/jwx/v2 v2.1.3 h1:Ud4lb2QuxRClYAmRleF50KrbKIoM1TddXgBrneT5/Jo= +github.com/lestrrat-go/jwx/v2 v2.1.3/go.mod h1:q6uFgbgZfEmQrfJfrCo90QcQOcXFMfbI/fO0NqRtvZo= +github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= +github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= 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/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= @@ -41,6 +61,8 @@ github.com/pressly/goose/v3 v3.24.1/go.mod h1:rEWreU9uVtt0DHCyLzF9gRcWiiTF/V+528 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= +github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= @@ -51,7 +73,9 @@ github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= diff --git a/backend/internal/api/api.go b/backend/internal/api/api.go index 2e4e5ee..efc9818 100644 --- a/backend/internal/api/api.go +++ b/backend/internal/api/api.go @@ -10,20 +10,31 @@ import ( "github.com/danielgtaylor/huma/v2/adapters/humachi" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/forfarm/backend/internal/domain" + m "github.com/forfarm/backend/internal/middlewares" + "github.com/forfarm/backend/internal/repository" ) type api struct { logger *slog.Logger httpClient *http.Client + + userRepo domain.UserRepository } -func NewAPI(ctx context.Context, logger *slog.Logger) *api { +func NewAPI(ctx context.Context, logger *slog.Logger, pool *pgxpool.Pool) *api { client := &http.Client{} + userRepository := repository.NewPostgresUser(pool) + return &api{ logger: logger, httpClient: client, + + userRepo: userRepository, } } @@ -34,14 +45,20 @@ func (a *api) Server(port int) *http.Server { } func (a *api) Routes() *chi.Mux { - r := chi.NewRouter() + router := chi.NewRouter() + router.Use(middleware.Logger) - r.Use(middleware.Logger) + config := huma.DefaultConfig("ForFarm Public API", "v1.0.0") + api := humachi.New(router, config) - api := humachi.New(r, huma.DefaultConfig("ForFarm API", "v1.0.0")) - huma.Get(api, "/helloworld", a.helloWorldHandler) + router.Group(func(r chi.Router) { + a.registerAuthRoutes(r, api) + }) - // r.Get("/helloworld", a.helloWorldHandler) + router.Group(func(r chi.Router) { + api.UseMiddleware(m.AuthMiddleware(api)) + a.registerHelloRoutes(r, api) + }) - return r + return router } diff --git a/backend/internal/api/auth.go b/backend/internal/api/auth.go new file mode 100644 index 0000000..b202f83 --- /dev/null +++ b/backend/internal/api/auth.go @@ -0,0 +1,115 @@ +package api + +import ( + "context" + "errors" + "net/http" + + "github.com/danielgtaylor/huma/v2" + "github.com/forfarm/backend/internal/domain" + "github.com/forfarm/backend/internal/utilities" + "github.com/go-chi/chi/v5" + "golang.org/x/crypto/bcrypt" +) + +func (a *api) registerAuthRoutes(_ chi.Router, api huma.API) { + tags := []string{"auth"} + + prefix := "/auth" + + huma.Register(api, huma.Operation{ + OperationID: "register", + Method: http.MethodPost, + Path: prefix + "/register", + Tags: tags, + }, a.registerHandler) + + huma.Register(api, huma.Operation{ + OperationID: "login", + Method: http.MethodPost, + Path: prefix + "/login", + Tags: tags, + }, a.loginHandler) +} + +type LoginInput struct { + Body struct { + Email string `json:"email" example:" Email address of the user"` + Password string `json:"password" example:" Password of the user"` + } +} + +type LoginOutput struct { + Body struct { + Token string `json:"token" example:" JWT token for the user"` + } +} + +type RegisterInput struct { + Body struct { + Email string `json:"email" example:" Email address of the user"` + Password string `json:"password" example:" Password of the user"` + } +} + +type RegisterOutput struct { + Body struct { + Token string `json:"token" example:" JWT token for the user"` + } +} + +func (a *api) registerHandler(ctx context.Context, input *RegisterInput) (*RegisterOutput, error) { + resp := &RegisterOutput{} + + // TODO: Validate input data + + _, err := a.userRepo.GetByEmail(ctx, input.Body.Email) + if err == domain.ErrNotFound { + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(input.Body.Password), bcrypt.DefaultCost) + if err != nil { + return nil, err + } + + err = a.userRepo.CreateOrUpdate(ctx, &domain.User{ + Email: input.Body.Email, + Password: string(hashedPassword), + }) + if err != nil { + return nil, err + } + + token, err := utilities.CreateJwtToken(input.Body.Email) + if err != nil { + return nil, err + } + + resp.Body.Token = token + return resp, nil + } + + return nil, errors.New("user already exists") +} + +func (a *api) loginHandler(ctx context.Context, input *LoginInput) (*LoginOutput, error) { + resp := &LoginOutput{} + + // TODO: Validate input data + + user, err := a.userRepo.GetByEmail(ctx, input.Body.Email) + if err != nil { + return nil, err + } + + // verify password hash + if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(input.Body.Password)); err != nil { + return nil, err + } + + token, err := utilities.CreateJwtToken(user.UUID) + if err != nil { + return nil, err + } + + resp.Body.Token = token + return resp, nil +} diff --git a/backend/internal/api/helloworld.go b/backend/internal/api/helloworld.go index a1d2664..df72e79 100644 --- a/backend/internal/api/helloworld.go +++ b/backend/internal/api/helloworld.go @@ -2,16 +2,37 @@ package api import ( "context" + "net/http" + + "github.com/danielgtaylor/huma/v2" + "github.com/go-chi/chi/v5" ) -type HelloworldOutput struct { +type HelloWorldInput struct { + MyHeader string `header:"Authorization" required:"true" example:"Bearer token"` +} + +type HelloWorldOutput struct { Body struct { - Message string `json:"message" example:"Hello, world!" doc:"Greeting message"` + Message string `json:"message" example:"hello world"` } } -func (a *api) helloWorldHandler(ctx context.Context, input *struct{}) (*HelloworldOutput, error) { - resp := &HelloworldOutput{} - resp.Body.Message = "Hello, world!" +func (a *api) registerHelloRoutes(_ chi.Router, api huma.API) { + tags := []string{"hello"} + + huma.Register(api, huma.Operation{ + OperationID: "helloWorld", + Method: http.MethodPost, + Path: "/hello", + Tags: tags, + Summary: "Get hello world message", + Description: "Returns a simple hello world message", + }, a.helloWorldHandler) +} + +func (a *api) helloWorldHandler(ctx context.Context, input *HelloWorldInput) (*HelloWorldOutput, error) { + resp := &HelloWorldOutput{} + resp.Body.Message = "hello world from forfarm" return resp, nil } diff --git a/backend/internal/cmd/api.go b/backend/internal/cmd/api.go index 2855e7a..816b457 100644 --- a/backend/internal/cmd/api.go +++ b/backend/internal/cmd/api.go @@ -29,7 +29,7 @@ func APICmd(ctx context.Context) *cobra.Command { } defer pool.Close() - api := api.NewAPI(ctx, logger) + api := api.NewAPI(ctx, logger, pool) server := api.Server(port) go func() { diff --git a/backend/internal/cmd/root.go b/backend/internal/cmd/root.go index af7086a..9dd0d88 100644 --- a/backend/internal/cmd/root.go +++ b/backend/internal/cmd/root.go @@ -2,7 +2,6 @@ package cmd import ( "context" - "net/http" "os" "github.com/joho/godotenv" @@ -20,10 +19,6 @@ func Execute(ctx context.Context) int { rootCmd.AddCommand(APICmd(ctx)) rootCmd.AddCommand(MigrateCmd(ctx, "pgx", os.Getenv("DATABASE_URL"))) - go func() { - _ = http.ListenAndServe("localhost:8000", nil) - }() - if err := rootCmd.Execute(); err != nil { return 1 } diff --git a/backend/internal/domain/cropland.go b/backend/internal/domain/cropland.go new file mode 100644 index 0000000..984e6e4 --- /dev/null +++ b/backend/internal/domain/cropland.go @@ -0,0 +1,36 @@ +package domain + +import ( + "context" + "time" + + validation "github.com/go-ozzo/ozzo-validation/v4" +) + +type Cropland struct { + UUID string + Name string + Status string + Priority int + LandSize float64 + GrowthStage string + PlantID string + FarmID string + CreatedAt time.Time + UpdatedAt time.Time +} + +func (c *Cropland) Validate() error { + return validation.ValidateStruct(c, + validation.Field(&c.Name, validation.Required), + validation.Field(&c.Status, validation.Required), + validation.Field(&c.GrowthStage, validation.Required), + validation.Field(&c.LandSize, validation.Required), + ) +} + +type CroplandRepository interface { + GetByID(context.Context, string) (Cropland, error) + CreateOrUpdate(context.Context, *Cropland) error + Delete(context.Context, string) error +} diff --git a/backend/internal/domain/farm.go b/backend/internal/domain/farm.go new file mode 100644 index 0000000..f631334 --- /dev/null +++ b/backend/internal/domain/farm.go @@ -0,0 +1,33 @@ +package domain + +import ( + "context" + "time" + + validation "github.com/go-ozzo/ozzo-validation/v4" +) + +type Farm struct { + UUID string + Name string + Lat float64 + Lon float64 + CreatedAt time.Time + UpdatedAt time.Time + OwnerID string +} + +func (f *Farm) Validate() error { + return validation.ValidateStruct(f, + validation.Field(&f.Name, validation.Required), + validation.Field(&f.Lat, validation.Required), + validation.Field(&f.Lon, validation.Required), + validation.Field(&f.OwnerID, validation.Required), + ) +} + +type FarmRepository interface { + GetByID(context.Context, string) (Farm, error) + CreateOrUpdate(context.Context, *Farm) error + Delete(context.Context, string) error +} diff --git a/backend/internal/domain/user.go b/backend/internal/domain/user.go index 9a2d58b..16fc939 100644 --- a/backend/internal/domain/user.go +++ b/backend/internal/domain/user.go @@ -27,7 +27,7 @@ func (u *User) NormalizedUsername() string { func (u *User) Validate() error { return validation.ValidateStruct(u, validation.Field(&u.UUID, validation.Required), - validation.Field(&u.Username, validation.Required, validation.Length(3, 20)), + validation.Field(&u.Username, validation.Length(3, 20)), validation.Field(&u.Password, validation.Required, validation.Length(6, 100)), validation.Field(&u.Email, validation.Required, is.Email), ) @@ -36,6 +36,7 @@ func (u *User) Validate() error { type UserRepository interface { GetByID(context.Context, int64) (User, error) GetByUsername(context.Context, string) (User, error) + GetByEmail(context.Context, string) (User, error) CreateOrUpdate(context.Context, *User) error Delete(context.Context, int64) error } diff --git a/backend/internal/middlewares/auth.go b/backend/internal/middlewares/auth.go new file mode 100644 index 0000000..058e07f --- /dev/null +++ b/backend/internal/middlewares/auth.go @@ -0,0 +1,35 @@ +package middlewares + +import ( + "net/http" + "strings" + + "github.com/danielgtaylor/huma/v2" + "github.com/forfarm/backend/internal/utilities" +) + +func AuthMiddleware(api huma.API) func(ctx huma.Context, next func(huma.Context)) { + return func(ctx huma.Context, next func(huma.Context)) { + authHeader := ctx.Header("Authorization") + if authHeader == "" { + huma.WriteErr(api, ctx, http.StatusUnauthorized, "No token provided") + return + } + + tokenStr := strings.TrimPrefix(authHeader, "Bearer ") + if tokenStr == "" { + huma.WriteErr(api, ctx, http.StatusUnauthorized, "No token provided") + return + } + + err := utilities.VerifyJwtToken(tokenStr) + + if err != nil { + huma.WriteErr(api, ctx, http.StatusUnauthorized, "Invalid token") + return + } + + next(ctx) + } + +} diff --git a/backend/internal/repository/postgres_cropland.go b/backend/internal/repository/postgres_cropland.go new file mode 100644 index 0000000..7cc48f8 --- /dev/null +++ b/backend/internal/repository/postgres_cropland.go @@ -0,0 +1,111 @@ +package repository + +import ( + "context" + "strings" + + "github.com/google/uuid" + + "github.com/forfarm/backend/internal/domain" +) + +type postgresCroplandRepository struct { + conn Connection +} + +func NewPostgresCropland(conn Connection) domain.CroplandRepository { + return &postgresCroplandRepository{conn: conn} +} + +func (p *postgresCroplandRepository) fetch(ctx context.Context, query string, args ...interface{}) ([]domain.Cropland, error) { + rows, err := p.conn.Query(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var croplands []domain.Cropland + for rows.Next() { + var c domain.Cropland + if err := rows.Scan( + &c.UUID, + &c.Name, + &c.Status, + &c.Priority, + &c.LandSize, + &c.GrowthStage, + &c.PlantID, + &c.FarmID, + &c.CreatedAt, + &c.UpdatedAt, + ); err != nil { + return nil, err + } + croplands = append(croplands, c) + } + return croplands, nil +} + +func (p *postgresCroplandRepository) GetByID(ctx context.Context, uuid string) (domain.Cropland, error) { + query := ` + SELECT uuid, name, status, priority, land_size, growth_stage, plant_id, farm_id, created_at, updated_at + FROM croplands + WHERE uuid = $1` + + croplands, err := p.fetch(ctx, query, uuid) + if err != nil { + return domain.Cropland{}, err + } + if len(croplands) == 0 { + return domain.Cropland{}, domain.ErrNotFound + } + return croplands[0], nil +} + +func (p *postgresCroplandRepository) GetByFarmID(ctx context.Context, farmID string) ([]domain.Cropland, error) { + query := ` + SELECT uuid, name, status, priority, land_size, growth_stage, plant_id, farm_id, created_at, updated_at + FROM croplands + WHERE farm_id = $1` + + return p.fetch(ctx, query, farmID) +} + +func (p *postgresCroplandRepository) CreateOrUpdate(ctx context.Context, c *domain.Cropland) error { + if strings.TrimSpace(c.UUID) == "" { + c.UUID = uuid.New().String() + } + + query := ` + INSERT INTO croplands (uuid, name, status, priority, land_size, growth_stage, plant_id, farm_id, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW()) + ON CONFLICT (uuid) DO UPDATE + SET name = EXCLUDED.name, + status = EXCLUDED.status, + priority = EXCLUDED.priority, + land_size = EXCLUDED.land_size, + growth_stage = EXCLUDED.growth_stage, + plant_id = EXCLUDED.plant_id, + farm_id = EXCLUDED.farm_id, + updated_at = NOW() + RETURNING uuid, created_at, updated_at` + + return p.conn.QueryRow( + ctx, + query, + c.UUID, + c.Name, + c.Status, + c.Priority, + c.LandSize, + c.GrowthStage, + c.PlantID, + c.FarmID, + ).Scan(&c.CreatedAt, &c.UpdatedAt) +} + +func (p *postgresCroplandRepository) Delete(ctx context.Context, uuid string) error { + query := `DELETE FROM croplands WHERE uuid = $1` + _, err := p.conn.Exec(ctx, query, uuid) + return err +} diff --git a/backend/internal/repository/postgres_farm.go b/backend/internal/repository/postgres_farm.go new file mode 100644 index 0000000..e0fa6a4 --- /dev/null +++ b/backend/internal/repository/postgres_farm.go @@ -0,0 +1,102 @@ +package repository + +import ( + "context" + "strings" + + "github.com/google/uuid" + + "github.com/forfarm/backend/internal/domain" +) + +type postgresFarmRepository struct { + conn Connection +} + +func NewPostgresFarm(conn Connection) domain.FarmRepository { + return &postgresFarmRepository{conn: conn} +} + +func (p *postgresFarmRepository) fetch(ctx context.Context, query string, args ...interface{}) ([]domain.Farm, error) { + rows, err := p.conn.Query(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var farms []domain.Farm + for rows.Next() { + var f domain.Farm + if err := rows.Scan( + &f.UUID, + &f.Name, + &f.Lat, + &f.Lon, + &f.CreatedAt, + &f.UpdatedAt, + &f.OwnerID, + ); err != nil { + return nil, err + } + farms = append(farms, f) + } + return farms, nil +} + +func (p *postgresFarmRepository) GetByID(ctx context.Context, uuid string) (domain.Farm, error) { + query := ` + SELECT uuid, name, lat, lon, created_at, updated_at, owner_id + FROM farms + WHERE uuid = $1` + + farms, err := p.fetch(ctx, query, uuid) + if err != nil { + return domain.Farm{}, err + } + if len(farms) == 0 { + return domain.Farm{}, domain.ErrNotFound + } + return farms[0], nil +} + +func (p *postgresFarmRepository) GetByOwnerID(ctx context.Context, ownerID string) ([]domain.Farm, error) { + query := ` + SELECT uuid, name, lat, lon, created_at, updated_at, owner_id + FROM farms + WHERE owner_id = $1` + + return p.fetch(ctx, query, ownerID) +} + +func (p *postgresFarmRepository) CreateOrUpdate(ctx context.Context, f *domain.Farm) error { + if strings.TrimSpace(f.UUID) == "" { + f.UUID = uuid.New().String() + } + + query := ` + INSERT INTO farms (uuid, name, lat, lon, created_at, updated_at, owner_id) + VALUES ($1, $2, $3, $4, NOW(), NOW(), $5) + ON CONFLICT (uuid) DO UPDATE + SET name = EXCLUDED.name, + lat = EXCLUDED.lat, + lon = EXCLUDED.lon, + updated_at = NOW(), + owner_id = EXCLUDED.owner_id + RETURNING uuid, created_at, updated_at` + + return p.conn.QueryRow( + ctx, + query, + f.UUID, + f.Name, + f.Lat, + f.Lon, + f.OwnerID, + ).Scan(&f.CreatedAt, &f.UpdatedAt) +} + +func (p *postgresFarmRepository) Delete(ctx context.Context, uuid string) error { + query := `DELETE FROM farms WHERE uuid = $1` + _, err := p.conn.Exec(ctx, query, uuid) + return err +} diff --git a/backend/internal/repository/postgres_user.go b/backend/internal/repository/postgres_user.go index 6619e96..67a00f5 100644 --- a/backend/internal/repository/postgres_user.go +++ b/backend/internal/repository/postgres_user.go @@ -78,15 +78,31 @@ func (p *postgresUserRepository) GetByUsername(ctx context.Context, username str return users[0], nil } -func (p *postgresUserRepository) CreateOrUpdate(ctx context.Context, u *domain.User) error { - if err := u.Validate(); err != nil { - return err - } +func (p *postgresUserRepository) GetByEmail(ctx context.Context, email string) (domain.User, error) { + query := ` + SELECT id, uuid, username, password, email, created_at, updated_at, is_active + FROM users + WHERE email = $1` + users, err := p.fetch(ctx, query, email) + if err != nil { + return domain.User{}, err + } + if len(users) == 0 { + return domain.User{}, domain.ErrNotFound + } + return users[0], nil +} + +func (p *postgresUserRepository) CreateOrUpdate(ctx context.Context, u *domain.User) error { if strings.TrimSpace(u.UUID) == "" { u.UUID = uuid.New().String() } + if err := u.Validate(); err != nil { + return err + } + u.NormalizedUsername() query := ` diff --git a/backend/internal/utilities/jwt.go b/backend/internal/utilities/jwt.go new file mode 100644 index 0000000..95fbbb0 --- /dev/null +++ b/backend/internal/utilities/jwt.go @@ -0,0 +1,45 @@ +package utilities + +import ( + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// TODO: Change later +var secretKey = []byte("secret-key") + +func CreateJwtToken(uuid string) (string, error) { + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "uuid": uuid, + "exp": time.Now().Add(time.Hour * 24).Unix(), + }) + + tokenString, err := token.SignedString(secretKey) + if err != nil { + return "", err + } + + return tokenString, nil +} + +func VerifyJwtToken(tokenString string) error { + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, jwt.ErrSignatureInvalid + } + + // TODO: CHANGE SECRET KEY + return secretKey, nil + }) + + if err != nil { + return err + } + + if !token.Valid { + return jwt.ErrSignatureInvalid + } + + return nil +} diff --git a/backend/migrations/00001_create_user_table.sql b/backend/migrations/00001_create_user_table.sql index 8b0b6d7..afa1499 100644 --- a/backend/migrations/00001_create_user_table.sql +++ b/backend/migrations/00001_create_user_table.sql @@ -2,7 +2,7 @@ CREATE TABLE users ( id SERIAL PRIMARY KEY, uuid UUID NOT NULL, - username TEXT NOT NULL, + username TEXT NULL, password TEXT NOT NULL, email TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), diff --git a/backend/migrations/00002_create_farm_and_cropland_tables.sql b/backend/migrations/00002_create_farm_and_cropland_tables.sql new file mode 100644 index 0000000..8f2df4b --- /dev/null +++ b/backend/migrations/00002_create_farm_and_cropland_tables.sql @@ -0,0 +1,67 @@ +-- +goose Up +CREATE TABLE light_profiles ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL UNIQUE +); + +CREATE TABLE soil_conditions ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL UNIQUE +); + +CREATE TABLE harvest_units ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL UNIQUE +); + +CREATE TABLE plants ( + uuid UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + variety TEXT, + row_spacing DOUBLE PRECISION, + optimal_temp DOUBLE PRECISION, + planting_depth DOUBLE PRECISION, + average_height DOUBLE PRECISION, + light_profile_id INT NOT NULL, + soil_condition_id INT NOT NULL, + planting_detail TEXT, + is_perennial BOOLEAN NOT NULL DEFAULT FALSE, + days_to_emerge INT, + days_to_flower INT, + days_to_maturity INT, + harvest_window INT, + ph_value DOUBLE PRECISION, + estimate_loss_rate DOUBLE PRECISION, + estimate_revenue_per_hu DOUBLE PRECISION, + harvest_unit_id INT NOT NULL, + water_needs DOUBLE PRECISION, + CONSTRAINT fk_plant_light_profile FOREIGN KEY (light_profile_id) REFERENCES light_profiles(id), + CONSTRAINT fk_plant_soil_condition FOREIGN KEY (soil_condition_id) REFERENCES soil_conditions(id), + CONSTRAINT fk_plant_harvest_unit FOREIGN KEY (harvest_unit_id) REFERENCES harvest_units(id) +); + +CREATE TABLE farms ( + uuid UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + lat DOUBLE PRECISION NOT NULL, + lon DOUBLE PRECISION NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + owner_id UUID NOT NULL, + CONSTRAINT fk_farm_owner FOREIGN KEY (owner_id) REFERENCES users(uuid) ON DELETE CASCADE +); + +CREATE TABLE croplands ( + uuid UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + status TEXT NOT NULL, + priority INT NOT NULL, + land_size DOUBLE PRECISION NOT NULL, + growth_stage TEXT NOT NULL, + plant_id UUID NOT NULL, + farm_id UUID NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT fk_cropland_farm FOREIGN KEY (farm_id) REFERENCES farms(uuid) ON DELETE CASCADE, + CONSTRAINT fk_cropland_plant FOREIGN KEY (plant_id) REFERENCES plants(uuid) ON DELETE CASCADE +); diff --git a/frontend/app/auth/signin/forgot-password-modal.tsx b/frontend/app/auth/signin/forgot-password-modal.tsx new file mode 100644 index 0000000..c2eb19c --- /dev/null +++ b/frontend/app/auth/signin/forgot-password-modal.tsx @@ -0,0 +1,49 @@ +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import Link from "next/link"; +import { Label } from "@/components/ui/label"; + +export default function ForgotPasswordModal() { + return ( +
+ + + + + + + What's your email? + + Please verify your email for us. Once you do, we'll send instructions to reset your password + + +
+
+ + +
+
+ + + + + +
+
+
+ ); +} diff --git a/frontend/app/auth/signin/page.tsx b/frontend/app/auth/signin/page.tsx new file mode 100644 index 0000000..55dc3dd --- /dev/null +++ b/frontend/app/auth/signin/page.tsx @@ -0,0 +1,76 @@ +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Button } from "@/components/ui/button"; +import ForgotPasswordModal from "./forgot-password-modal"; + +import Link from "next/link"; +import Image from "next/image"; + +export default function SigninPage() { + return ( +
+
+
+ +
+ {/* login box */} +
+
+ + Forfarm + +

Welcome back.

+
+ New to Forfarm? + + + Sign up + + +
+
+ +
+
+ + +
+
+
+ + +
+
+ + +
+ + + +
+

Or log in with

+ {/* OAUTH */} +
+ {/* Google */} +
+ Google Logo +
+
+
+
+
+
+
+ ); +} diff --git a/frontend/app/auth/signin/waterdrop.tsx b/frontend/app/auth/signin/waterdrop.tsx new file mode 100644 index 0000000..b06bb2f --- /dev/null +++ b/frontend/app/auth/signin/waterdrop.tsx @@ -0,0 +1,12 @@ +import React from "react"; + +const WaterDrop = () => { + return ( +
+ {/* Water Drop animation */} +
+
+ ); +}; + +export default WaterDrop; diff --git a/frontend/app/auth/signup/page.tsx b/frontend/app/auth/signup/page.tsx new file mode 100644 index 0000000..5657830 --- /dev/null +++ b/frontend/app/auth/signup/page.tsx @@ -0,0 +1,69 @@ +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Button } from "@/components/ui/button"; + +import Link from "next/link"; +import Image from "next/image"; + +export default function SignupPage() { + return ( +
+
+
+ +
+ {/* login box */} +
+
+ + Forfarm + +

Hi! Welcome

+
+ Already have accounts? + + + Sign in + + +
+
+ +
+
+ + +
+
+
+ + +
+
+
+
+ + +
+
+ + +
+ +
+

Or log in with

+ {/* OAUTH */} +
+ {/* Google */} +
+ Google Logo +
+
+
+
+
+
+
+ ); +} diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 4fd7492..491435b 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -33,6 +33,14 @@ body { --chart-3: 197 37% 24%; --chart-4: 43 74% 66%; --chart-5: 27 87% 67%; + --sidebar-background: 0 0% 98%; + --sidebar-foreground: 240 5.3% 26.1%; + --sidebar-primary: 240 5.9% 10%; + --sidebar-primary-foreground: 0 0% 98%; + --sidebar-accent: 240 4.8% 95.9%; + --sidebar-accent-foreground: 240 5.9% 10%; + --sidebar-border: 220 13% 91%; + --sidebar-ring: 217.2 91.2% 59.8%; } .dark { @@ -60,6 +68,14 @@ body { --chart-3: 30 80% 55%; --chart-4: 280 65% 60%; --chart-5: 340 75% 55%; + --sidebar-background: 240 5.9% 10%; + --sidebar-foreground: 240 4.8% 95.9%; + --sidebar-primary: 224.3 76.3% 48%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 240 3.7% 15.9%; + --sidebar-accent-foreground: 240 4.8% 95.9%; + --sidebar-border: 240 3.7% 15.9%; + --sidebar-ring: 217.2 91.2% 59.8%; } } diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index f7fa87e..86c19f8 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -1,20 +1,28 @@ import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; +import { Open_Sans, Roboto_Mono } from "next/font/google"; import "./globals.css"; +import { ThemeProvider } from "@/components/theme-provider"; -const geistSans = Geist({ - variable: "--font-geist-sans", +const openSans = Open_Sans({ subsets: ["latin"], + display: "swap", + variable: "--font-opensans", }); -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", +const robotoMono = Roboto_Mono({ subsets: ["latin"], + display: "swap", + variable: "--font-roboto-mono", }); +// const geistMono = Geist_Mono({ +// variable: "--font-geist-mono", +// subsets: ["latin"], +// }); + export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "ForFarm - Smart Farming Solutions", + description: "Optimize your agricultural business with AI-driven insights and real-time data.", }; export default function RootLayout({ @@ -23,11 +31,14 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - - - {children} + + + + +
+
{children}
+
+
); diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 9007252..1d68f3c 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -1,100 +1,68 @@ import Image from "next/image"; +import Link from "next/link"; +import { ArrowRight, Cloud, BarChart, Zap } from "lucide-react"; +import { Leaf } from "lucide-react"; + +import { Button } from "@/components/ui/button"; export default function Home() { return ( -
-
- Next.js logo -
    -
  1. - Get started by editing{" "} - - app/page.tsx - - . -
  2. -
  3. Save and see your changes instantly.
  4. -
+
+
+ + + + ForFarm + + + + + Documentation + + + Get started + + +
-
- - Vercel logomark - Deploy now - - - Read our docs - +
+
+ ForFarm Icon +

Your Smart Farming Platform

+

+ It's a smart and easy way to optimize your agricultural business, with the help of AI-driven insights and + real-time data. +

+ + +
-
); diff --git a/frontend/app/setup/google-map-with-drawing.tsx b/frontend/app/setup/google-map-with-drawing.tsx new file mode 100644 index 0000000..46ddbd5 --- /dev/null +++ b/frontend/app/setup/google-map-with-drawing.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { GoogleMap, LoadScript, DrawingManager } from "@react-google-maps/api"; +import { useState, useCallback } from "react"; + +const containerStyle = { + width: "100%", + height: "500px", +}; + +const center = { lat: 13.7563, lng: 100.5018 }; // Example: Bangkok, Thailand + +const GoogleMapWithDrawing = () => { + const [map, setMap] = useState(null); + + // Handles drawing complete + const onDrawingComplete = useCallback( + (overlay: google.maps.drawing.OverlayCompleteEvent) => { + console.log("Drawing complete:", overlay); + }, + [] + ); + + return ( + + setMap(map)} + > + {map && ( + + )} + + + ); +}; + +export default GoogleMapWithDrawing; diff --git a/frontend/app/setup/harvest-detail-form.tsx b/frontend/app/setup/harvest-detail-form.tsx new file mode 100644 index 0000000..38e2124 --- /dev/null +++ b/frontend/app/setup/harvest-detail-form.tsx @@ -0,0 +1,230 @@ +"use client"; + +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { harvestDetailsFormSchema } from "@/schemas/application.schema"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +type harvestSchema = z.infer; + +export default function HarvestDetailsForm() { + const form = useForm({ + resolver: zodResolver(harvestDetailsFormSchema), + defaultValues: {}, + }); + return ( +
+ + ( + + + Days To Flower + + +
+
+ +
+
+
+ +
+ )} + /> + ( + + + Days To Maturity + + +
+
+ +
+
+
+ +
+ )} + /> + ( + + + Harvest Window + + +
+
+ +
+
+
+ +
+ )} + /> + ( + + + Estimated Loss Rate + + +
+
+ +
+
+
+ +
+ )} + /> + ( + + Harvest Units + + + + + + )} + /> + ( + + + Estimated Revenue + + +
+
+ +
+
+
+ +
+ )} + /> + ( + + + Expected Yield Per100ft + + +
+
+ +
+
+
+ +
+ )} + /> + ( + + + Expected Yield Per Acre + + +
+
+ +
+
+
+ +
+ )} + /> + + + ); +} diff --git a/frontend/app/setup/layout.tsx b/frontend/app/setup/layout.tsx new file mode 100644 index 0000000..393f507 --- /dev/null +++ b/frontend/app/setup/layout.tsx @@ -0,0 +1,43 @@ +import { AppSidebar } from "@/components/app-sidebar"; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from "@/components/ui/breadcrumb"; +import { Separator } from "@/components/ui/separator"; +import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; + +export default function AppLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + +
+
+ + + + + + Building Your Application + + + + Data Fetching + + + +
+
+ {children} +
+
+ ); +} diff --git a/frontend/app/setup/page.tsx b/frontend/app/setup/page.tsx new file mode 100644 index 0000000..223ec01 --- /dev/null +++ b/frontend/app/setup/page.tsx @@ -0,0 +1,34 @@ +import PlantingDetailsForm from "./planting-detail-form"; +import HarvestDetailsForm from "./harvest-detail-form"; +import { Separator } from "@/components/ui/separator"; +import GoogleMapWithDrawing from "./google-map-with-drawing"; + +export default function SetupPage() { + return ( +
+
+

Plating Details

+
+ +
+ +
+
+

Harvest Details

+
+ +
+ +
+
+
+

Map

+
+ +
+ +
+
+
+ ); +} diff --git a/frontend/app/setup/planting-detail-form.tsx b/frontend/app/setup/planting-detail-form.tsx new file mode 100644 index 0000000..e6cc1c2 --- /dev/null +++ b/frontend/app/setup/planting-detail-form.tsx @@ -0,0 +1,314 @@ +"use client"; + +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { plantingDetailsFormSchema } from "@/schemas/application.schema"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import { Switch } from "@/components/ui/switch"; + +type plantingSchema = z.infer; + +export default function PlantingDetailsForm() { + const form = useForm({ + resolver: zodResolver(plantingDetailsFormSchema), + defaultValues: {}, + }); + return ( +
+ + ( + + Day to Emerge + +
+
+ +
+
+
+ +
+ )} + /> + ( + + Plant Spacing + +
+
+ +
+
+
+ +
+ )} + /> + ( + + Row Spacing + +
+
+ +
+
+
+ +
+ )} + /> + ( + + + Planting Depth + + +
+
+ +
+
+
+ +
+ )} + /> + ( + + + Average Height + + +
+
+ +
+
+
+ +
+ )} + /> + ( + + Start Method + + + + + + )} + /> + ( + + Light Profile + + + + + + )} + /> + ( + + + Soil Conditions + + + + + + + )} + /> + ( + + + Planting Details + + +
+
+