From bf14542b430b9065dbe1a1f6371423f0c2cab2b0 Mon Sep 17 00:00:00 2001 From: Sosokker Date: Wed, 12 Feb 2025 16:20:01 +0700 Subject: [PATCH] feat: add air, huma, db setup and migrations --- backend/.air.toml | 1 + backend/go.mod | 20 ++- backend/go.sum | 68 ++++++++++ backend/internal/api/api.go | 47 +++++++ backend/internal/api/helloworld.go | 17 +++ backend/internal/cmd/api.go | 54 ++++++++ backend/internal/cmd/migrate.go | 42 +++++++ backend/internal/cmd/root.go | 27 +++- backend/internal/cmdutil/cmdutil.go | 28 +++++ backend/internal/domain/errors.go | 10 ++ backend/internal/domain/user.go | 43 +++++++ backend/internal/embed.go | 8 ++ .../migrations/00001_create_users.sql | 13 ++ backend/internal/repository/connection.go | 8 -- backend/internal/repository/postgres_user.go | 118 ++++++++++++++++++ 15 files changed, 489 insertions(+), 15 deletions(-) create mode 100644 backend/internal/api/api.go create mode 100644 backend/internal/api/helloworld.go create mode 100644 backend/internal/cmd/api.go create mode 100644 backend/internal/cmd/migrate.go create mode 100644 backend/internal/cmdutil/cmdutil.go create mode 100644 backend/internal/domain/errors.go create mode 100644 backend/internal/domain/user.go create mode 100644 backend/internal/embed.go create mode 100644 backend/internal/migrations/00001_create_users.sql create mode 100644 backend/internal/repository/postgres_user.go diff --git a/backend/.air.toml b/backend/.air.toml index 5c1b449..026f712 100644 --- a/backend/.air.toml +++ b/backend/.air.toml @@ -4,6 +4,7 @@ tmp_dir = "tmp" [build] cmd = "go build -o ./tmp/api ./cmd/forfarm" bin = "./tmp/api" +args_bin = ["api"] include_ext = ["go", "tpl", "tmpl", "html"] exclude_dir = ["assets", "tmp", "vendor"] delay = 1000 diff --git a/backend/go.mod b/backend/go.mod index a405b2f..1280452 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -3,9 +3,27 @@ module github.com/forfarm/backend 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-ozzo/ozzo-validation/v4 v4.3.0 + github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.7.2 + github.com/joho/godotenv v1.5.1 + 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/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/pgx/v5 v5.7.2 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/mfridman/interpolate v0.0.2 // 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/text v0.21.0 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index 32a40dd..8459453 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,17 +1,85 @@ +github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496 h1:zV3ejI06GQ59hwDQAvmK1qxOQGB3WuVTRoY0okPTAv0= +github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/danielgtaylor/huma/v2 v2.28.0 h1:W+hIT52MigO73edJNJWXU896uC99xSBWpKoE2PRyybM= +github.com/danielgtaylor/huma/v2 v2.28.0/go.mod h1:67KO0zmYEkR+LVUs8uqrcvf44G1wXiMIu94LV/cH2Ek= 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/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-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/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= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI= github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= +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/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= +github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pressly/goose/v3 v3.24.1 h1:bZmxRco2uy5uu5Ng1MMVEfYsFlrMJI+e/VMXHQ3C4LY= +github.com/pressly/goose/v3 v3.24.1/go.mod h1:rEWreU9uVtt0DHCyLzF9gRcWiiTF/V+528DV+4DORug= +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/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= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 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.7.0/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= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= +modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= +modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= +modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= +modernc.org/sqlite v1.34.1 h1:u3Yi6M0N8t9yKRDwhXcyp1eS5/ErhPTBggxWFuR6Hfk= +modernc.org/sqlite v1.34.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/backend/internal/api/api.go b/backend/internal/api/api.go new file mode 100644 index 0000000..2e4e5ee --- /dev/null +++ b/backend/internal/api/api.go @@ -0,0 +1,47 @@ +package api + +import ( + "context" + "fmt" + "log/slog" + "net/http" + + "github.com/danielgtaylor/huma/v2" + "github.com/danielgtaylor/huma/v2/adapters/humachi" + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" +) + +type api struct { + logger *slog.Logger + httpClient *http.Client +} + +func NewAPI(ctx context.Context, logger *slog.Logger) *api { + + client := &http.Client{} + + return &api{ + logger: logger, + httpClient: client, + } +} + +func (a *api) Server(port int) *http.Server { + return &http.Server{ + Addr: fmt.Sprintf(":%d", port), + Handler: a.Routes()} +} + +func (a *api) Routes() *chi.Mux { + r := chi.NewRouter() + + r.Use(middleware.Logger) + + api := humachi.New(r, huma.DefaultConfig("ForFarm API", "v1.0.0")) + huma.Get(api, "/helloworld", a.helloWorldHandler) + + // r.Get("/helloworld", a.helloWorldHandler) + + return r +} diff --git a/backend/internal/api/helloworld.go b/backend/internal/api/helloworld.go new file mode 100644 index 0000000..a1d2664 --- /dev/null +++ b/backend/internal/api/helloworld.go @@ -0,0 +1,17 @@ +package api + +import ( + "context" +) + +type HelloworldOutput struct { + Body struct { + Message string `json:"message" example:"Hello, world!" doc:"Greeting message"` + } +} + +func (a *api) helloWorldHandler(ctx context.Context, input *struct{}) (*HelloworldOutput, error) { + resp := &HelloworldOutput{} + resp.Body.Message = "Hello, world!" + return resp, nil +} diff --git a/backend/internal/cmd/api.go b/backend/internal/cmd/api.go new file mode 100644 index 0000000..2855e7a --- /dev/null +++ b/backend/internal/cmd/api.go @@ -0,0 +1,54 @@ +package cmd + +import ( + "context" + "os" + + "log/slog" + "net/http" + + "github.com/spf13/cobra" + + "github.com/forfarm/backend/internal/api" + "github.com/forfarm/backend/internal/cmdutil" +) + +func APICmd(ctx context.Context) *cobra.Command { + var port int = 8000 + + cmd := &cobra.Command{ + Use: "api", + Args: cobra.ExactArgs(0), + Short: "Run RESTful API", + RunE: func(cmd *cobra.Command, args []string) error { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + + pool, err := cmdutil.NewDatabasePool(ctx, 16) + if err != nil { + return err + } + defer pool.Close() + + api := api.NewAPI(ctx, logger) + server := api.Server(port) + + go func() { + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + logger.Error("failed to start server", "err", err) + } + }() + + logger.Info("started API", "port", port) + + <-ctx.Done() + + if err := server.Shutdown(ctx); err != nil { + logger.Error("failed to gracefully shutdown server", "err", err) + } + + return nil + }, + } + + return cmd +} diff --git a/backend/internal/cmd/migrate.go b/backend/internal/cmd/migrate.go new file mode 100644 index 0000000..b902432 --- /dev/null +++ b/backend/internal/cmd/migrate.go @@ -0,0 +1,42 @@ +package cmd + +import ( + "context" + "database/sql" + "fmt" + "log/slog" + "os" + + _ "github.com/jackc/pgx/v5/stdlib" + "github.com/pressly/goose/v3" + "github.com/spf13/cobra" + + migrations "github.com/forfarm/backend/internal" +) + +func MigrateCmd(ctx context.Context, dbDriver, dbSource string) *cobra.Command { + cmd := &cobra.Command{ + Use: "migrate", + Short: "Run database migrations", + RunE: func(cmd *cobra.Command, args []string) error { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + + db, err := sql.Open(dbDriver, dbSource) + if err != nil { + return fmt.Errorf("failed to open database: %w", err) + } + defer db.Close() + + goose.SetBaseFS(migrations.EmbedMigrations) + + if err := goose.UpContext(ctx, db, "migrations"); err != nil { + return fmt.Errorf("failed to run migrations: %w", err) + } + + logger.Info("Database migrations have been applied successfully") + return nil + }, + } + + return cmd +} diff --git a/backend/internal/cmd/root.go b/backend/internal/cmd/root.go index 6aadea7..af7086a 100644 --- a/backend/internal/cmd/root.go +++ b/backend/internal/cmd/root.go @@ -2,16 +2,31 @@ package cmd import ( "context" - "fmt" + "net/http" + "os" + + "github.com/joho/godotenv" + "github.com/spf13/cobra" ) -// Execute is a placeholder function that represents the central execution point of the application. -// It accepts a context for managing cancellation and timeouts and returns an integer exit code. -// Currently, it simply prints a message and returns 0. func Execute(ctx context.Context) int { - fmt.Println("Execute placeholder logic runs here!") + _ = godotenv.Load() - // Future code should check for context cancellation or incorporate your application's logic. + rootCmd := &cobra.Command{ + Use: "forfarm", + Short: "A smart farming software uses AI, weather data, and analytics to help farmers make better decisions and improve productivity", + } + + 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 + } return 0 } diff --git a/backend/internal/cmdutil/cmdutil.go b/backend/internal/cmdutil/cmdutil.go new file mode 100644 index 0000000..4e2f7d3 --- /dev/null +++ b/backend/internal/cmdutil/cmdutil.go @@ -0,0 +1,28 @@ +package cmdutil + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +func NewDatabasePool(ctx context.Context, maxConns int) (*pgxpool.Pool, error) { + if maxConns == 0 { + maxConns = 1 + } + + url := fmt.Sprintf("%s?pool_max_conns=%d&pool_min_conns=%d", os.Getenv("DATABASE_URL"), maxConns, 2) + config, err := pgxpool.ParseConfig(url) + if err != nil { + return nil, err + } + + config.ConnConfig.DefaultQueryExecMode = pgx.QueryExecModeSimpleProtocol + config.MaxConnLifetime = 1 * time.Hour + config.MaxConnIdleTime = 30 * time.Second + return pgxpool.NewWithConfig(ctx, config) +} diff --git a/backend/internal/domain/errors.go b/backend/internal/domain/errors.go new file mode 100644 index 0000000..991600f --- /dev/null +++ b/backend/internal/domain/errors.go @@ -0,0 +1,10 @@ +package domain + +import "errors" + +var ( + // ErrNotFound will be returned if the requested item is not found + ErrNotFound = errors.New("requested item was not found") + // ErrConflict will be returned if the item being persisted already exists + ErrConflict = errors.New("item already exists") +) diff --git a/backend/internal/domain/user.go b/backend/internal/domain/user.go new file mode 100644 index 0000000..d6c967b --- /dev/null +++ b/backend/internal/domain/user.go @@ -0,0 +1,43 @@ +package domain + +import ( + "context" + "strings" + "time" + + validation "github.com/go-ozzo/ozzo-validation/v4" + "github.com/go-ozzo/ozzo-validation/v4/is" +) + +const UserRefreshInterval = 2 * time.Minute + +type User struct { + ID int64 + UUID string + Username string + Password string + Email string + CreatedAt time.Time + UpdatedAt time.Time + IsActive bool +} + +func (u *User) NormalizedUsername() string { + return strings.ToLower(u.Username) +} + +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.Password, validation.Required, validation.Length(6, 100)), + validation.Field(&u.Email, validation.Required, is.Email), + ) +} + +type UserRepository interface { + GetByID(context.Context, int64) (User, error) + GetByUsername(context.Context, string) (User, error) + CreateOrUpdate(context.Context, *User) error + Delete(context.Context, int64) error +} diff --git a/backend/internal/embed.go b/backend/internal/embed.go new file mode 100644 index 0000000..0df75f5 --- /dev/null +++ b/backend/internal/embed.go @@ -0,0 +1,8 @@ +package migrations + +import ( + "embed" +) + +//go:embed migrations/*.sql +var EmbedMigrations embed.FS diff --git a/backend/internal/migrations/00001_create_users.sql b/backend/internal/migrations/00001_create_users.sql new file mode 100644 index 0000000..7bc50be --- /dev/null +++ b/backend/internal/migrations/00001_create_users.sql @@ -0,0 +1,13 @@ +CREATE TABLE users ( + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL, + username TEXT NOT NULL, + password TEXT NOT NULL, + email TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + is_active BOOLEAN NOT NULL DEFAULT TRUE +); + +CREATE UNIQUE INDEX idx_users_uuid ON users(uuid); +CREATE UNIQUE INDEX idx_users_username ON users(username); \ No newline at end of file diff --git a/backend/internal/repository/connection.go b/backend/internal/repository/connection.go index 74a9960..63d6305 100644 --- a/backend/internal/repository/connection.go +++ b/backend/internal/repository/connection.go @@ -7,16 +7,8 @@ import ( "github.com/jackc/pgx/v5/pgconn" ) -// Connection represents a simplified interface for database operations. type Connection interface { Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) Query(context.Context, string, ...interface{}) (pgx.Rows, error) QueryRow(context.Context, string, ...interface{}) pgx.Row } - -// placeholderSpanWithQuery is a dummy function for tracking queries. -// It serves as a placeholder and currently only returns the given context. -func placeholderSpanWithQuery(ctx context.Context, query string) context.Context { - // In a real implementation, you might log the query or attach metrics. - return ctx -} diff --git a/backend/internal/repository/postgres_user.go b/backend/internal/repository/postgres_user.go new file mode 100644 index 0000000..6619e96 --- /dev/null +++ b/backend/internal/repository/postgres_user.go @@ -0,0 +1,118 @@ +package repository + +import ( + "context" + "strings" + + "github.com/google/uuid" + + "github.com/forfarm/backend/internal/domain" +) + +type postgresUserRepository struct { + conn Connection +} + +func NewPostgresUser(conn Connection) domain.UserRepository { + return &postgresUserRepository{conn: conn} +} + +func (p *postgresUserRepository) fetch(ctx context.Context, query string, args ...interface{}) ([]domain.User, error) { + rows, err := p.conn.Query(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var users []domain.User + for rows.Next() { + var u domain.User + if err := rows.Scan( + &u.ID, + &u.UUID, + &u.Username, + &u.Password, + &u.Email, + &u.CreatedAt, + &u.UpdatedAt, + &u.IsActive, + ); err != nil { + return nil, err + } + users = append(users, u) + } + return users, nil +} + +func (p *postgresUserRepository) GetByID(ctx context.Context, id int64) (domain.User, error) { + query := ` + SELECT id, uuid, username, password, email, created_at, updated_at, is_active + FROM users + WHERE id = $1` + + users, err := p.fetch(ctx, query, id) + if err != nil { + return domain.User{}, err + } + if len(users) == 0 { + return domain.User{}, domain.ErrNotFound + } + return users[0], nil +} + +func (p *postgresUserRepository) GetByUsername(ctx context.Context, username string) (domain.User, error) { + query := ` + SELECT id, uuid, username, password, email, created_at, updated_at, is_active + FROM users + WHERE username = $1` + + username = strings.ToLower(username) + + users, err := p.fetch(ctx, query, username) + 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 err := u.Validate(); err != nil { + return err + } + + if strings.TrimSpace(u.UUID) == "" { + u.UUID = uuid.New().String() + } + + u.NormalizedUsername() + + query := ` + INSERT INTO users (uuid, username, password, email, created_at, updated_at, is_active) + VALUES ($1, $2, $3, $4, NOW(), NOW(), $5) + ON CONFLICT (uuid) DO UPDATE + SET username = EXCLUDED.username, + password = EXCLUDED.password, + email = EXCLUDED.email, + updated_at = NOW(), + is_active = EXCLUDED.is_active + RETURNING id, created_at, updated_at` + + return p.conn.QueryRow( + ctx, + query, + u.UUID, + u.Username, + u.Password, + u.Email, + u.IsActive, + ).Scan(&u.ID, &u.CreatedAt, &u.UpdatedAt) +} + +func (p *postgresUserRepository) Delete(ctx context.Context, id int64) error { + query := `DELETE FROM users WHERE id = $1` + _, err := p.conn.Exec(ctx, query, id) + return err +}