From bfc48d4eb20b86fd20c72535323c9a6776c24e71 Mon Sep 17 00:00:00 2001 From: codermuss Date: Sat, 3 Aug 2024 12:22:30 +0300 Subject: [PATCH 1/6] =?UTF-8?q?=E2=9C=A8=20init=20bg=20tasks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Makefile | 6 ++- api/category_test.go | 2 +- api/login_test.go | 2 +- api/main_test.go | 5 ++- api/middleware_test.go | 2 +- api/post_test.go | 4 +- api/register.go | 15 +++++++ api/register_test.go | 29 +++++++----- api/server.go | 16 ++++--- api/tag_test.go | 2 +- go.mod | 8 ++++ go.sum | 42 ++++++++++++++++++ locales/assets/en.json | 3 ++ locales/localekeys.g.go | 1 + mail/sender.go | 65 +++++++++++++++++++++++++++ mail/sender_test.go | 28 ++++++++++++ main.go | 35 +++++++++++++-- util/config.go | 4 ++ worker/distributor.go | 21 +++++++++ worker/logger.go | 52 ++++++++++++++++++++++ worker/mock/distributor.go | 56 ++++++++++++++++++++++++ worker/processor.go | 62 ++++++++++++++++++++++++++ worker/task_send_verify_email.go | 75 ++++++++++++++++++++++++++++++++ 23 files changed, 504 insertions(+), 31 deletions(-) create mode 100644 mail/sender.go create mode 100644 mail/sender_test.go create mode 100644 worker/distributor.go create mode 100644 worker/logger.go create mode 100644 worker/mock/distributor.go create mode 100644 worker/processor.go create mode 100644 worker/task_send_verify_email.go diff --git a/Makefile b/Makefile index e656361..c5fa634 100644 --- a/Makefile +++ b/Makefile @@ -29,8 +29,12 @@ server: mock: mockgen -package mockdb -destination db/mock/store.go github.com/mustafayilmazdev/musarchive/db/sqlc Store + mockgen -package mockdb -destination worker/mock/distributor.go github.com/mustafayilmazdev/musarchive/worker TaskDistributor locale: musale --json=locales/assets/en.json --output=locales/localekeys.go -p=localization -.PHONY: postgres migrateup1 migrateup migratedown migratedown1 new_migration test sqlc server mock locale +redis: + docker run --name redis -p 6379:6379 -d redis:7.4-alpine + +.PHONY: postgres migrateup1 migrateup migratedown migratedown1 new_migration test sqlc server mock locale redis diff --git a/api/category_test.go b/api/category_test.go index df695c0..4327f18 100644 --- a/api/category_test.go +++ b/api/category_test.go @@ -71,7 +71,7 @@ func TestGetCategories(t *testing.T) { store := mockdb.NewMockStore(ctrl) tc.buildStubs(store) - server := newTestServer(t, store) + server := newTestServer(t, store, nil) recorder := httptest.NewRecorder() url := "/v1/categories/index" diff --git a/api/login_test.go b/api/login_test.go index 557fee5..dda35dd 100644 --- a/api/login_test.go +++ b/api/login_test.go @@ -121,7 +121,7 @@ func TestLoginUserAPI(t *testing.T) { store := mockdb.NewMockStore(ctrl) tc.buildStubs(store) - server := newTestServer(t, store) + server := newTestServer(t, store, nil) recorder := httptest.NewRecorder() // Marshal body data to JSON diff --git a/api/main_test.go b/api/main_test.go index bfa2c90..3150af1 100644 --- a/api/main_test.go +++ b/api/main_test.go @@ -9,11 +9,12 @@ import ( db "github.com/mustafayilmazdev/musarchive/db/sqlc" localization "github.com/mustafayilmazdev/musarchive/locales" "github.com/mustafayilmazdev/musarchive/util" + "github.com/mustafayilmazdev/musarchive/worker" "github.com/rs/zerolog/log" "github.com/stretchr/testify/require" ) -func newTestServer(t *testing.T, store db.Store) *Server { +func newTestServer(t *testing.T, store db.Store, taskDistributor worker.TaskDistributor) *Server { config := util.Config{ TokenSymetricKey: util.RandomString(32), AccessTokenDuration: time.Minute, @@ -24,7 +25,7 @@ func newTestServer(t *testing.T, store db.Store) *Server { log.Fatal().Msg("Can not load localization") } - server, err := NewServer(config, store) + server, err := NewServer(config, store, taskDistributor) require.NoError(t, err) return server } diff --git a/api/middleware_test.go b/api/middleware_test.go index 7cf03f0..cabb037 100644 --- a/api/middleware_test.go +++ b/api/middleware_test.go @@ -80,7 +80,7 @@ func TestAuthMiddleware(t *testing.T) { tc := testCases[i] t.Run(tc.name, func(t *testing.T) { - server := newTestServer(t, nil) + server := newTestServer(t, nil, nil) authPath := "/auth" server.Router.GET( authPath, diff --git a/api/post_test.go b/api/post_test.go index 85168dc..34863be 100644 --- a/api/post_test.go +++ b/api/post_test.go @@ -120,7 +120,7 @@ func TestCreatePostAPI(t *testing.T) { store := mockdb.NewMockStore(ctrl) tc.buildStubs(store) - server := newTestServer(t, store) + server := newTestServer(t, store, nil) recorder := httptest.NewRecorder() data, err := json.Marshal(tc.body) @@ -303,7 +303,7 @@ func TestGetPostsAPI(t *testing.T) { store := mockdb.NewMockStore(ctrl) tc.buildStubs(store) - server := newTestServer(t, store) + server := newTestServer(t, store, nil) recorder := httptest.NewRecorder() data, err := json.Marshal(tc.body) diff --git a/api/register.go b/api/register.go index 0419d9a..9b04a19 100644 --- a/api/register.go +++ b/api/register.go @@ -11,6 +11,7 @@ import ( db "github.com/mustafayilmazdev/musarchive/db/sqlc" localization "github.com/mustafayilmazdev/musarchive/locales" "github.com/mustafayilmazdev/musarchive/util" + "github.com/mustafayilmazdev/musarchive/worker" ) type registerUserRequest struct { @@ -104,6 +105,20 @@ func (server *Server) RegisterUser(ctx *gin.Context) { }) return } + taskPayload := &worker.PayloadSendVerifyEmail{ + Username: userAndProfile.User.Username, + } + err = server.taskDistributor.DistributeTaskSendVerifyEmail(ctx, taskPayload) + if err != nil { + BuildResponse(ctx, BaseResponse{ + Code: http.StatusInternalServerError, + Message: ResponseMessage{ + Type: ERROR, + Content: server.lm.Translate(localeValue, localization.Task_FailedDistribute, err), + }, + }) + return + } BuildResponse(ctx, BaseResponse{ Code: http.StatusOK, diff --git a/api/register_test.go b/api/register_test.go index 353a950..6850887 100644 --- a/api/register_test.go +++ b/api/register_test.go @@ -17,6 +17,7 @@ import ( mockdb "github.com/mustafayilmazdev/musarchive/db/mock" db "github.com/mustafayilmazdev/musarchive/db/sqlc" "github.com/mustafayilmazdev/musarchive/util" + mockwk "github.com/mustafayilmazdev/musarchive/worker/mock" "github.com/stretchr/testify/require" ) @@ -60,7 +61,7 @@ func TestCreateUserAPI(t *testing.T) { testCases := []struct { name string body gin.H - buildStubs func(store *mockdb.MockStore) + buildStubs func(store *mockdb.MockStore, taskDistributor *mockwk.MockTaskDistributor) checkResponse func(recoder *httptest.ResponseRecorder) }{ { @@ -74,7 +75,7 @@ func TestCreateUserAPI(t *testing.T) { "role": user.Role, "birth_date": user.BirthDate, }, - buildStubs: func(store *mockdb.MockStore) { + buildStubs: func(store *mockdb.MockStore, taskDistributor *mockwk.MockTaskDistributor) { arg := db.InsertUserParams{ Username: user.Username, FullName: user.FullName, @@ -102,7 +103,7 @@ func TestCreateUserAPI(t *testing.T) { "full_name": user.FullName, "email": user.Email, }, - buildStubs: func(store *mockdb.MockStore) { + buildStubs: func(store *mockdb.MockStore, taskDistributor *mockwk.MockTaskDistributor) { store.EXPECT(). InsertUser(gomock.Any(), gomock.Any()). Times(1). @@ -120,7 +121,7 @@ func TestCreateUserAPI(t *testing.T) { "full_name": user.FullName, "email": user.Email, }, - buildStubs: func(store *mockdb.MockStore) { + buildStubs: func(store *mockdb.MockStore, taskDistributor *mockwk.MockTaskDistributor) { store.EXPECT(). InsertUser(gomock.Any(), gomock.Any()). Times(0) @@ -137,7 +138,7 @@ func TestCreateUserAPI(t *testing.T) { "full_name": user.FullName, "email": user.Email, }, - buildStubs: func(store *mockdb.MockStore) { + buildStubs: func(store *mockdb.MockStore, taskDistributor *mockwk.MockTaskDistributor) { store.EXPECT(). InsertUser(gomock.Any(), gomock.Any()). Times(1). @@ -155,7 +156,7 @@ func TestCreateUserAPI(t *testing.T) { "full_name": user.FullName, "email": "invalid-email", }, - buildStubs: func(store *mockdb.MockStore) { + buildStubs: func(store *mockdb.MockStore, taskDistributor *mockwk.MockTaskDistributor) { store.EXPECT(). InsertUser(gomock.Any(), gomock.Any()). Times(0) @@ -172,7 +173,7 @@ func TestCreateUserAPI(t *testing.T) { "full_name": user.FullName, "email": user.Email, }, - buildStubs: func(store *mockdb.MockStore) { + buildStubs: func(store *mockdb.MockStore, taskDistributor *mockwk.MockTaskDistributor) { store.EXPECT(). InsertUser(gomock.Any(), gomock.Any()). Times(0) @@ -188,11 +189,15 @@ func TestCreateUserAPI(t *testing.T) { tc := testCases[i] t.Run(tc.name, func(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - store := mockdb.NewMockStore(ctrl) - tc.buildStubs(store) - server := newTestServer(t, store) + + storeCtrl := gomock.NewController(t) + defer storeCtrl.Finish() + store := mockdb.NewMockStore(storeCtrl) + taskCtrl := gomock.NewController(t) + defer taskCtrl.Finish() + taskDistributor := mockwk.NewMockTaskDistributor(taskCtrl) + tc.buildStubs(store, taskDistributor) + server := newTestServer(t, store, taskDistributor) recorder := httptest.NewRecorder() // Marshal body data to JSON data, err := json.Marshal(tc.body) diff --git a/api/server.go b/api/server.go index 8104c8c..4165aae 100644 --- a/api/server.go +++ b/api/server.go @@ -9,25 +9,27 @@ import ( localization "github.com/mustafayilmazdev/musarchive/locales" "github.com/mustafayilmazdev/musarchive/token" "github.com/mustafayilmazdev/musarchive/util" + "github.com/mustafayilmazdev/musarchive/worker" "github.com/rakyll/statik/fs" ) // * Note [codermuss]: Server serves HTTP requests for our banking service. type Server struct { - config util.Config - store db.Store - tokenMaker token.Maker - Router *gin.Engine - lm *localization.LocalizationManager + config util.Config + store db.Store + tokenMaker token.Maker + Router *gin.Engine + lm *localization.LocalizationManager + taskDistributor worker.TaskDistributor } // * Note [codermuss]: NewServer creates a new HTTP server and setup routing -func NewServer(config util.Config, store db.Store) (*Server, error) { +func NewServer(config util.Config, store db.Store, taskDistributor worker.TaskDistributor) (*Server, error) { tokenMaker, err := token.NewJWTMaker(config.TokenSymetricKey) if err != nil { return nil, fmt.Errorf("cannot create token maker: %w", err) } - server := &Server{config: config, store: store, tokenMaker: tokenMaker, lm: localization.GetInstance()} + server := &Server{config: config, store: store, tokenMaker: tokenMaker, lm: localization.GetInstance(), taskDistributor: taskDistributor} server.setupRouter() return server, nil diff --git a/api/tag_test.go b/api/tag_test.go index 9b583cc..fbcfc33 100644 --- a/api/tag_test.go +++ b/api/tag_test.go @@ -71,7 +71,7 @@ func TestGetTags(t *testing.T) { store := mockdb.NewMockStore(ctrl) tc.buildStubs(store) - server := newTestServer(t, store) + server := newTestServer(t, store, nil) recorder := httptest.NewRecorder() url := "/v1/tags/index" diff --git a/go.mod b/go.mod index 626bd99..10b792c 100644 --- a/go.mod +++ b/go.mod @@ -22,9 +22,11 @@ require ( require ( github.com/bytedance/sonic v1.11.9 // indirect github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/iasm v0.2.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/gabriel-vasile/mimetype v1.4.4 // indirect github.com/gin-contrib/sse v0.1.0 // indirect @@ -32,12 +34,15 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.22.0 // indirect github.com/goccy/go-json v0.10.3 // indirect + github.com/golang/protobuf v1.5.4 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/hcl v1.0.0 // indirect + github.com/hibiken/asynq v0.24.1 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect github.com/jackc/puddle/v2 v2.2.1 // indirect + github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.2.8 // indirect github.com/leodido/go-urn v1.4.0 // indirect @@ -50,6 +55,8 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/redis/go-redis/v9 v9.6.1 // indirect + github.com/robfig/cron/v3 v3.0.1 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect @@ -65,6 +72,7 @@ require ( golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect golang.org/x/net v0.27.0 // indirect golang.org/x/sys v0.22.0 // indirect + golang.org/x/time v0.5.0 // indirect google.golang.org/protobuf v1.34.2 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index b955d39..7c4a21c 100644 --- a/go.sum +++ b/go.sum @@ -4,10 +4,15 @@ github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8 github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= +github.com/bsm/ginkgo/v2 v2.7.0/go.mod h1:AiKlXPm7ItEHNc/2+OkrNG4E0ITzojb9/xWzvQ9XZ9w= +github.com/bsm/gomega v1.26.0/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/bytedance/sonic v1.11.9 h1:LFHENlIY/SLzDWverzdOvgMztTxcfcF+cqNsz9pK5zg= github.com/bytedance/sonic v1.11.9/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= @@ -19,6 +24,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dhui/dktest v0.4.1 h1:/w+IWuDXVymg3IrRJCHHOkMK10m9aNVMOyD0X12YVTg= github.com/dhui/dktest v0.4.1/go.mod h1:DdOqcUpL7vgyP4GlF3X3w7HbSlz8cEQzwewPveYEQbA= github.com/docker/distribution v2.8.2+incompatible h1:T3de5rq0dB1j30rp0sA2rER+m322EBzniBPB6ZIzuh8= @@ -56,9 +63,16 @@ github.com/golang-migrate/migrate/v4 v4.17.1 h1:4zQ6iqL6t6AiItphxJctQb3cFqWiSpMn github.com/golang-migrate/migrate/v4 v4.17.1/go.mod h1:m8hinFyWBn0SA4QKHuKh175Pm9wjmxj3S2Mia7dbXzM= github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -68,6 +82,8 @@ github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+l github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hibiken/asynq v0.24.1 h1:+5iIEAyA9K/lcSPvx3qoPtsKJeKI5u9aOIvUmSsazEw= +github.com/hibiken/asynq v0.24.1/go.mod h1:u5qVeSbrnfT+vtG5Mq8ZPzQu/BmCKMHvTGb91uy9Tts= 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-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= @@ -76,14 +92,19 @@ github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible h1:jdpOPRN1zP63Td1hDQbZW73xKmzDvZHzVdNYxhnTMDA= +github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible/go.mod h1:1c7szIrayyPPB/987hsnvNzLushdWf4o/79s3P08L8A= 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/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM= github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= @@ -124,6 +145,11 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rakyll/statik v0.1.7 h1:OF3QCZUuyPxuGEP7B4ypUa7sB/iHtqOTDYZXGM8KOdQ= github.com/rakyll/statik v0.1.7/go.mod h1:AlZONWzMtEnMs7W4e/1LURLiI49pIMmp6V9Unghqrcc= +github.com/redis/go-redis/v9 v9.0.3/go.mod h1:WqMKv5vnQbRuZstUwxQI195wHy+t4PuXDOjzMvcuQHk= +github.com/redis/go-redis/v9 v9.6.1 h1:HHDteefn6ZkTtY5fGUE8tj8uy85AHk6zP7CpzIAM0y4= +github.com/redis/go-redis/v9 v9.6.1/go.mod h1:0C0c6ycQsdpVNQpxb1njEQIqkx5UcsM8FJCQLgE9+RA= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= @@ -137,6 +163,7 @@ github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9yS github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= @@ -147,6 +174,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= @@ -164,6 +192,7 @@ github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZ github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/goleak v1.1.12/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= @@ -175,9 +204,11 @@ golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= @@ -192,6 +223,7 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -203,17 +235,27 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= diff --git a/locales/assets/en.json b/locales/assets/en.json index 929d224..aa1bcd2 100644 --- a/locales/assets/en.json +++ b/locales/assets/en.json @@ -33,5 +33,8 @@ }, "Post":{ "InsertSuccess":"Post created successfully!" + }, + "Task":{ + "FailedDistribute":"Failed to distribute task to send verify email: {{.arg0}}" } } \ No newline at end of file diff --git a/locales/localekeys.g.go b/locales/localekeys.g.go index 701ce0d..e66907b 100644 --- a/locales/localekeys.g.go +++ b/locales/localekeys.g.go @@ -20,6 +20,7 @@ const ( Pagination_sizeError = "Pagination.sizeError" Post_InsertSuccess = "Post.InsertSuccess" Success_Migrate = "Success.Migrate" + Task_FailedDistribute = "Task.FailedDistribute" User_LogoutSuccess = "User.LogoutSuccess" User_RegisterSuccess = "User.RegisterSuccess" ) diff --git a/mail/sender.go b/mail/sender.go new file mode 100644 index 0000000..0b3f587 --- /dev/null +++ b/mail/sender.go @@ -0,0 +1,65 @@ +package mail + +import ( + "fmt" + "net/smtp" + + "github.com/jordan-wright/email" +) + +const ( + smtpAuthAddress = "smtp.gmail.com" + smtpServerAddress = "smtp.gmail.com:587" +) + +type EmailSender interface { + SendEmail( + subject string, + content string, + to []string, + cc []string, + bcc []string, + attachFiles []string, + ) error +} + +type GmailSender struct { + name string + fromEmailAddress string + fromEmailPassword string +} + +func NewGmailSender(name, fromEmailAddress, fromEmailPassword string) EmailSender { + return &GmailSender{ + name: name, + fromEmailAddress: fromEmailAddress, + fromEmailPassword: fromEmailPassword, + } +} + +func (sender *GmailSender) SendEmail( + subject string, + content string, + to []string, + cc []string, + bcc []string, + attachFiles []string, +) error { + e := email.NewEmail() + e.From = fmt.Sprintf("%s<%s>", sender.name, sender.fromEmailAddress) + e.Subject = subject + e.HTML = []byte(content) + e.To = to + e.Cc = cc + e.Bcc = bcc + + for _, f := range attachFiles { + _, err := e.AttachFile(f) + if err != nil { + return fmt.Errorf("failed to attach file %s: %w", f, err) + } + } + smtpAuth := smtp.PlainAuth("", sender.fromEmailAddress, sender.fromEmailPassword, smtpAuthAddress) + return e.Send(smtpServerAddress, smtpAuth) + +} diff --git a/mail/sender_test.go b/mail/sender_test.go new file mode 100644 index 0000000..26a6709 --- /dev/null +++ b/mail/sender_test.go @@ -0,0 +1,28 @@ +package mail + +import ( + "testing" + + "github.com/mustafayilmazdev/musarchive/util" + "github.com/stretchr/testify/require" +) + +func TestSendEmailWithGmail(t *testing.T) { + if testing.Short() { + t.Skip() + } + config, err := util.LoadConfig("..") + require.NoError(t, err) + + sender := NewGmailSender(config.EmailSenderName, config.EmailSenderAddress, config.EmailSenderPassword) + + subject := "A test email" + content := ` +

Hello world

+

This is a test message from Mus

+ ` + to := []string{"mustafaylmz173@gmail.com"} + attachFiles := []string{"../README.md"} + err = sender.SendEmail(subject, content, to, nil, nil, attachFiles) + require.NoError(t, err) +} diff --git a/main.go b/main.go index d27dd88..cb69552 100644 --- a/main.go +++ b/main.go @@ -9,10 +9,13 @@ import ( "os/signal" "syscall" + "github.com/hibiken/asynq" "github.com/jackc/pgx/v5/pgxpool" "github.com/mustafayilmazdev/musarchive/api" db "github.com/mustafayilmazdev/musarchive/db/sqlc" localization "github.com/mustafayilmazdev/musarchive/locales" + "github.com/mustafayilmazdev/musarchive/mail" + "github.com/mustafayilmazdev/musarchive/worker" "golang.org/x/sync/errgroup" @@ -61,7 +64,14 @@ func main() { runDBMigration(config.MigrationUrl, config.DBSource) store := db.NewStore(connPool) waitGroup, ctx := errgroup.WithContext(ctx) - runGinServer(config, ctx, waitGroup, store) + + redisOpt := asynq.RedisClientOpt{ + Addr: config.RedisAddress, + } + + taskDistributor := worker.NewRedisTaskDistributor(redisOpt) + go runTaskProcessor(config, ctx, waitGroup, redisOpt, store) + runGinServer(config, ctx, waitGroup, store, taskDistributor) err = waitGroup.Wait() if err != nil { @@ -81,9 +91,28 @@ func runDBMigration(migrationUrl, dbSource string) { log.Info().Msg(lm.Translate(util.DefaultLocale, localization.Success_Migrate)) } -func runGinServer(config util.Config, ctx context.Context, waitGroup *errgroup.Group, store db.Store) { +func runTaskProcessor(config util.Config, ctx context.Context, waitGroup *errgroup.Group, redisOpt asynq.RedisClientOpt, store db.Store) { + mailer := mail.NewGmailSender(config.EmailSenderName, config.EmailSenderAddress, config.EmailSenderPassword) + taskProcessor := worker.NewRedisTaskProcessor(redisOpt, store, mailer) + log.Info().Msg("start task processor") + err := taskProcessor.Start() + if err != nil { + log.Fatal().Err(err).Msg("failed to start task processor") + } + + waitGroup.Go(func() error { + <-ctx.Done() + log.Info().Msg("graceful shutdown task processor") + + taskProcessor.ShutDown() + log.Info().Msg("task processor is stopped") + return nil + }) +} + +func runGinServer(config util.Config, ctx context.Context, waitGroup *errgroup.Group, store db.Store, taskDistributor worker.TaskDistributor) { - server, err := api.NewServer(config, store) + server, err := api.NewServer(config, store, taskDistributor) if err != nil { fmt.Println(err) } diff --git a/util/config.go b/util/config.go index bf4aa10..74be830 100644 --- a/util/config.go +++ b/util/config.go @@ -17,6 +17,10 @@ type Config struct { TokenSymetricKey string `mapstructure:"TOKEN_SYMMETRIC_KEY"` AccessTokenDuration time.Duration `mapstructure:"ACCESS_TOKEN_DURATION"` RefreshTokenDuration time.Duration `mapstructure:"REFRESH_TOKEN_DURATION"` + EmailSenderName string `mapstructure:"EMAIL_SENDER_NAME"` + EmailSenderAddress string `mapstructure:"EMAIL_SENDER_ADDRESS"` + EmailSenderPassword string `mapstructure:"EMAIL_SENDER_PASSWORD"` + RedisAddress string `mapstructure:"REDIS_ADDRESS"` } // * Note [codermuss]: LoadConfig reads configuration from file or environment variables. diff --git a/worker/distributor.go b/worker/distributor.go new file mode 100644 index 0000000..d4c106d --- /dev/null +++ b/worker/distributor.go @@ -0,0 +1,21 @@ +package worker + +import ( + "context" + + "github.com/hibiken/asynq" +) + +type TaskDistributor interface { + DistributeTaskSendVerifyEmail(ctx context.Context, payload *PayloadSendVerifyEmail, opts ...asynq.Option) error +} +type RedisTaskDistributor struct { + client *asynq.Client +} + +func NewRedisTaskDistributor(redisOpt asynq.RedisClientOpt) TaskDistributor { + client := asynq.NewClient(redisOpt) + return &RedisTaskDistributor{ + client: client, + } +} diff --git a/worker/logger.go b/worker/logger.go new file mode 100644 index 0000000..d44c279 --- /dev/null +++ b/worker/logger.go @@ -0,0 +1,52 @@ +package worker + +import ( + "context" + "fmt" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" +) + +type Logger struct { +} + +func NewLogger() *Logger { + return &Logger{} +} + +func (logger *Logger) Print(level zerolog.Level, args ...interface{}) { + log.WithLevel(level).Msg(fmt.Sprint(args...)) +} + +// Debug logs a message at Debug level. +func (logger *Logger) Debug(args ...interface{}) { + logger.Print(zerolog.DebugLevel, args...) +} + +// Info logs a message at Info level. +func (logger *Logger) Info(args ...interface{}) { + logger.Print(zerolog.InfoLevel, args...) +} + +// Warn logs a message at Warning level. +func (logger *Logger) Warn(args ...interface{}) { + logger.Print(zerolog.WarnLevel, args...) + +} + +// Error logs a message at Error level. +func (logger *Logger) Error(args ...interface{}) { + logger.Print(zerolog.ErrorLevel, args...) + +} + +// Fatal logs a message at Fatal level +// and process will exit with status set to 1. +func (logger *Logger) Fatal(args ...interface{}) { + logger.Print(zerolog.FatalLevel, args...) +} + +func (logger *Logger) Printf(ctx context.Context, format string, v ...interface{}) { + log.WithLevel(zerolog.DebugLevel).Msgf(format, v...) +} diff --git a/worker/mock/distributor.go b/worker/mock/distributor.go new file mode 100644 index 0000000..7a8f224 --- /dev/null +++ b/worker/mock/distributor.go @@ -0,0 +1,56 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/mustafayilmazdev/musarchive/worker (interfaces: TaskDistributor) + +// Package mockdb is a generated GoMock package. +package mockdb + +import ( + context "context" + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + asynq "github.com/hibiken/asynq" + worker "github.com/mustafayilmazdev/musarchive/worker" +) + +// MockTaskDistributor is a mock of TaskDistributor interface. +type MockTaskDistributor struct { + ctrl *gomock.Controller + recorder *MockTaskDistributorMockRecorder +} + +// MockTaskDistributorMockRecorder is the mock recorder for MockTaskDistributor. +type MockTaskDistributorMockRecorder struct { + mock *MockTaskDistributor +} + +// NewMockTaskDistributor creates a new mock instance. +func NewMockTaskDistributor(ctrl *gomock.Controller) *MockTaskDistributor { + mock := &MockTaskDistributor{ctrl: ctrl} + mock.recorder = &MockTaskDistributorMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockTaskDistributor) EXPECT() *MockTaskDistributorMockRecorder { + return m.recorder +} + +// DistributeTaskSendVerifyEmail mocks base method. +func (m *MockTaskDistributor) DistributeTaskSendVerifyEmail(arg0 context.Context, arg1 *worker.PayloadSendVerifyEmail, arg2 ...asynq.Option) error { + m.ctrl.T.Helper() + varargs := []interface{}{arg0, arg1} + for _, a := range arg2 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "DistributeTaskSendVerifyEmail", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +// DistributeTaskSendVerifyEmail indicates an expected call of DistributeTaskSendVerifyEmail. +func (mr *MockTaskDistributorMockRecorder) DistributeTaskSendVerifyEmail(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0, arg1}, arg2...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DistributeTaskSendVerifyEmail", reflect.TypeOf((*MockTaskDistributor)(nil).DistributeTaskSendVerifyEmail), varargs...) +} diff --git a/worker/processor.go b/worker/processor.go new file mode 100644 index 0000000..18a0f78 --- /dev/null +++ b/worker/processor.go @@ -0,0 +1,62 @@ +package worker + +import ( + "context" + + "github.com/hibiken/asynq" + db "github.com/mustafayilmazdev/musarchive/db/sqlc" + "github.com/mustafayilmazdev/musarchive/mail" + "github.com/redis/go-redis/v9" + "github.com/rs/zerolog/log" +) + +const ( + QueueCritical = "critical" + QueueDefault = "default" +) + +type TaskProcessor interface { + Start() error + ShutDown() + ProcessTaskSendVerifyEmail(ctx context.Context, task *asynq.Task) error +} + +type RedisTaskProcessor struct { + server *asynq.Server + store db.Store + mailer mail.EmailSender +} + +func NewRedisTaskProcessor(redisOpt asynq.RedisClientOpt, store db.Store, mailer mail.EmailSender) TaskProcessor { + logger := NewLogger() + redis.SetLogger(logger) + server := asynq.NewServer( + redisOpt, + asynq.Config{ + Queues: map[string]int{ + QueueCritical: 10, + QueueDefault: 5, + }, + ErrorHandler: asynq.ErrorHandlerFunc(func(ctx context.Context, task *asynq.Task, err error) { + log.Error().Err(err).Str("type", task.Type()).Bytes("payload", task.Payload()).Msg("process task failed") + }), + Logger: logger, + }, + ) + return &RedisTaskProcessor{ + server: server, + store: store, + mailer: mailer, + } +} + +func (processor *RedisTaskProcessor) Start() error { + mux := asynq.NewServeMux() + + mux.HandleFunc(TaskSendVerifyEmail, processor.ProcessTaskSendVerifyEmail) + return processor.server.Start(mux) +} + +func (processor *RedisTaskProcessor) ShutDown() { + processor.server.Shutdown() +} diff --git a/worker/task_send_verify_email.go b/worker/task_send_verify_email.go new file mode 100644 index 0000000..cfeb2f7 --- /dev/null +++ b/worker/task_send_verify_email.go @@ -0,0 +1,75 @@ +package worker + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/hibiken/asynq" + "github.com/rs/zerolog/log" +) + +const TaskSendVerifyEmail = "task:send_verify_email" + +type PayloadSendVerifyEmail struct { + Username string `json:"username"` +} + +func (distributor *RedisTaskDistributor) DistributeTaskSendVerifyEmail(ctx context.Context, payload *PayloadSendVerifyEmail, opts ...asynq.Option) error { + jsonPayload, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to parse payload: %w", err) + } + task := asynq.NewTask(TaskSendVerifyEmail, jsonPayload, opts...) + info, err := distributor.client.EnqueueContext(ctx, task) + if err != nil { + return fmt.Errorf("failed to enqueue task: %w", err) + } + log.Info().Str("type", task.Type()).Bytes("payload", task.Payload()).Str("queue", info.Queue).Int("max_retry", info.MaxRetry).Msg("enqueued task") + return nil +} + +func (processor *RedisTaskProcessor) ProcessTaskSendVerifyEmail(ctx context.Context, task *asynq.Task) error { + // var payload PayloadSendVerifyEmail + // if err := json.Unmarshal(task.Payload(), &payload); err != nil { + // return fmt.Errorf("failed to unmarshal payload: %w", asynq.SkipRetry) + // } + + // user, err := processor.store.GetUser(ctx, payload.Username) + // if err != nil { + // // if errors.Is(err, db.ErrRecordNotFound) { + // // return fmt.Errorf("user doesn't exist: %w", asynq.SkipRetry) + // // } + // return fmt.Errorf("failed to get user: %w", err) + + // } + + // verifyEmail, err := processor.store.CreateVerifyEmail(ctx, db.CreateVerifyEmailParams{ + // Username: payload.Username, + // Email: user.Email, + // SecretCode: util.RandomString(32), + // }) + + // if err != nil { + // return fmt.Errorf("failed to create verify email: %w", err) + // } + // subject := "Welcome to Simple Bank" + // verifyUrl := fmt.Sprintf("http://localhost:8080/v1/verify_email?email_id=%d&secret_code=%s", verifyEmail.ID, verifyEmail.SecretCode) + // content := fmt.Sprintf(` + // Hello %s,
+ // Thank you for registering with us!
+ // Please click here to verify your email address.
+ // `, user.FullName, verifyUrl) + + // to := []string{user.Email} + + // err = processor.mailer.SendEmail(subject, content, to, nil, nil, nil) + + // if err != nil { + // return fmt.Errorf("failed to send verify email: %w", err) + // } + + // log.Info().Str("type", task.Type()).Bytes("payload", task.Payload()).Str("queue", user.Email).Msg("enqueued task") + return nil + +} From fe23036ffff1c82c58978c01ba7feaff674914af Mon Sep 17 00:00:00 2001 From: codermuss Date: Sat, 3 Aug 2024 12:53:42 +0300 Subject: [PATCH 2/6] =?UTF-8?q?=E2=9C=A8=20add=20sending=20email=20to=20ve?= =?UTF-8?q?rify=20user?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/login.go | 13 +++++ api/register.go | 45 ++++++++++------- db/migration/000001_initial.down.sql | 4 +- db/migration/000001_initial.up.sql | 13 +++++ db/mock/store.go | 30 ++++++++++++ db/query/users.sql | 6 +-- db/query/verify_email.sql | 20 ++++++++ db/sqlc/models.go | 11 +++++ db/sqlc/querier.go | 2 + db/sqlc/users.sql.go | 15 ++++-- db/sqlc/verify_email.sql.go | 73 ++++++++++++++++++++++++++++ locales/assets/en.json | 1 + locales/localekeys.g.go | 1 + worker/task_send_verify_email.go | 66 +++++++++++++------------ 14 files changed, 242 insertions(+), 58 deletions(-) create mode 100644 db/query/verify_email.sql create mode 100644 db/sqlc/verify_email.sql.go diff --git a/api/login.go b/api/login.go index 64ae41e..bb0e827 100644 --- a/api/login.go +++ b/api/login.go @@ -9,6 +9,7 @@ import ( "github.com/google/uuid" db "github.com/mustafayilmazdev/musarchive/db/sqlc" + localization "github.com/mustafayilmazdev/musarchive/locales" "github.com/mustafayilmazdev/musarchive/util" ) @@ -40,6 +41,7 @@ func newUserResponse(user db.User) UserResponse { } func (server *Server) LoginUser(ctx *gin.Context) { + locale := ctx.Query(util.Locale) var req loginUserRequest if err := ctx.ShouldBindJSON(&req); err != nil { BuildResponse(ctx, BaseResponse{ @@ -85,6 +87,17 @@ func (server *Server) LoginUser(ctx *gin.Context) { return } + if !user.IsEmailVerified { + BuildResponse(ctx, BaseResponse{ + Code: http.StatusUnauthorized, + Message: ResponseMessage{ + Type: ERROR, + Content: server.lm.Translate(locale, localization.User_VerifyEmail), + }, + }) + return + } + accessToken, accessPayload, err := server.tokenMaker.CreateToken(int(user.ID), user.Role, server.config.AccessTokenDuration) if err != nil { BuildResponse(ctx, BaseResponse{ diff --git a/api/register.go b/api/register.go index 9b04a19..82f86b1 100644 --- a/api/register.go +++ b/api/register.go @@ -1,11 +1,11 @@ package api import ( - "fmt" "net/http" "time" "github.com/gin-gonic/gin" + "github.com/hibiken/asynq" "github.com/jackc/pgx/v5/pgtype" db "github.com/mustafayilmazdev/musarchive/db/sqlc" @@ -35,6 +35,11 @@ type UserResponse struct { CreatedAt time.Time `json:"created_at"` } +type RegisterResponse struct { + User UserResponse `json:"user"` + Profile db.Profile `json:"profile"` +} + func (server *Server) RegisterUser(ctx *gin.Context) { localeValue := ctx.Query("locale") @@ -72,8 +77,13 @@ func (server *Server) RegisterUser(ctx *gin.Context) { BirthDate: req.BirthDate, } argAfterCreate := func(user db.User) error { - fmt.Println("after create triggered") - return nil + taskPayload := &worker.PayloadSendVerifyEmail{Username: user.Username} + opts := []asynq.Option{ + asynq.MaxRetry(10), + asynq.ProcessIn(10 * time.Second), + asynq.Queue(worker.QueueCritical), + } + return server.taskDistributor.DistributeTaskSendVerifyEmail(ctx, taskPayload, opts...) } arg := db.RegisterUserTxParams{ @@ -105,24 +115,25 @@ func (server *Server) RegisterUser(ctx *gin.Context) { }) return } - taskPayload := &worker.PayloadSendVerifyEmail{ - Username: userAndProfile.User.Username, - } - err = server.taskDistributor.DistributeTaskSendVerifyEmail(ctx, taskPayload) - if err != nil { - BuildResponse(ctx, BaseResponse{ - Code: http.StatusInternalServerError, - Message: ResponseMessage{ - Type: ERROR, - Content: server.lm.Translate(localeValue, localization.Task_FailedDistribute, err), - }, - }) - return + + registerResponse := RegisterResponse{ + User: UserResponse{ + ID: userAndProfile.User.ID, + Username: userAndProfile.User.Username, + FullName: userAndProfile.User.FullName, + Email: userAndProfile.User.Email, + Role: userAndProfile.User.Role, + Avatar: userAndProfile.User.Avatar, + BirthDate: userAndProfile.User.BirthDate, + PasswordChangedAt: userAndProfile.User.PasswordChangedAt, + CreatedAt: userAndProfile.User.CreatedAt, + }, + Profile: userAndProfile.Profile, } BuildResponse(ctx, BaseResponse{ Code: http.StatusOK, - Data: userAndProfile, + Data: registerResponse, Message: ResponseMessage{ Type: SUCCESS, Content: server.lm.Translate(localeValue, localization.User_RegisterSuccess), diff --git a/db/migration/000001_initial.down.sql b/db/migration/000001_initial.down.sql index cae8256..53d13de 100644 --- a/db/migration/000001_initial.down.sql +++ b/db/migration/000001_initial.down.sql @@ -15,6 +15,7 @@ ALTER TABLE "post_categories" DROP CONSTRAINT "post_categories_post_id_fkey"; ALTER TABLE "post_tags" DROP CONSTRAINT "post_tags_tag_id_fkey"; ALTER TABLE "post_tags" DROP CONSTRAINT "post_tags_post_id_fkey"; ALTER TABLE "posts" DROP CONSTRAINT "posts_user_id_fkey"; +ALTER TABLE "verify_emails" DROP CONSTRAINT "verify_emails_user_id_fkey"; -- Drop tables in reverse order of creation DROP TABLE IF EXISTS "sessions"; @@ -30,4 +31,5 @@ DROP TABLE IF EXISTS "tags"; DROP TABLE IF EXISTS "posts"; DROP TABLE IF EXISTS "categories"; DROP TABLE IF EXISTS "users"; -DROP TABLE IF EXISTS "onboarding"; \ No newline at end of file +DROP TABLE IF EXISTS "onboarding"; +DROP TABLE IF EXISTS "verify_emails"; \ No newline at end of file diff --git a/db/migration/000001_initial.up.sql b/db/migration/000001_initial.up.sql index ebac2f1..61cbe34 100644 --- a/db/migration/000001_initial.up.sql +++ b/db/migration/000001_initial.up.sql @@ -14,10 +14,21 @@ CREATE TABLE "users" ( "avatar" varchar, "role" varchar(20) NOT NULL DEFAULT 'standard', "birth_date" date NOT NULL DEFAULT '0001-01-01', + "is_email_verified" bool NOT NULL DEFAULT false, "password_changed_at" timestamptz NOT NULL DEFAULT '0001-01-01 00:00:00Z', "created_at" timestamptz NOT NULL DEFAULT (now()) ); +CREATE TABLE "verify_emails" ( + "id" INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + "username" varchar(50) NOT NULL, + "email" varchar(50) NOT NULL, + "secret_code" varchar NOT NULL, + "is_used" bool NOT NULL DEFAULT false, + "created_at" timestamptz NOT NULL DEFAULT (now()), + "expired_at" timestamptz NOT NULL DEFAULT (now() + interval '15 minutes') +); + CREATE TABLE "categories" ( "id" INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "name" varchar(50) UNIQUE NOT NULL @@ -102,6 +113,8 @@ CREATE TABLE "sessions" ( "created_at" timestamptz NOT NULL DEFAULT (now()) ); +ALTER TABLE "verify_emails" ADD FOREIGN KEY ("username") REFERENCES "users" ("username"); + ALTER TABLE "posts" ADD FOREIGN KEY ("user_id") REFERENCES "users" ("id"); ALTER TABLE "post_tags" ADD FOREIGN KEY ("post_id") REFERENCES "posts" ("id"); diff --git a/db/mock/store.go b/db/mock/store.go index 5a66cef..e8f1b46 100644 --- a/db/mock/store.go +++ b/db/mock/store.go @@ -36,6 +36,21 @@ func (m *MockStore) EXPECT() *MockStoreMockRecorder { return m.recorder } +// CreateVerifyEmail mocks base method. +func (m *MockStore) CreateVerifyEmail(arg0 context.Context, arg1 db.CreateVerifyEmailParams) (db.VerifyEmail, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateVerifyEmail", arg0, arg1) + ret0, _ := ret[0].(db.VerifyEmail) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateVerifyEmail indicates an expected call of CreateVerifyEmail. +func (mr *MockStoreMockRecorder) CreateVerifyEmail(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateVerifyEmail", reflect.TypeOf((*MockStore)(nil).CreateVerifyEmail), arg0, arg1) +} + // DeleteCategory mocks base method. func (m *MockStore) DeleteCategory(arg0 context.Context, arg1 int32) error { m.ctrl.T.Helper() @@ -861,3 +876,18 @@ func (mr *MockStoreMockRecorder) UpdateUser(arg0, arg1 interface{}) *gomock.Call mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUser", reflect.TypeOf((*MockStore)(nil).UpdateUser), arg0, arg1) } + +// UpdateVerifyEmail mocks base method. +func (m *MockStore) UpdateVerifyEmail(arg0 context.Context, arg1 db.UpdateVerifyEmailParams) (db.VerifyEmail, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateVerifyEmail", arg0, arg1) + ret0, _ := ret[0].(db.VerifyEmail) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateVerifyEmail indicates an expected call of UpdateVerifyEmail. +func (mr *MockStoreMockRecorder) UpdateVerifyEmail(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateVerifyEmail", reflect.TypeOf((*MockStore)(nil).UpdateVerifyEmail), arg0, arg1) +} diff --git a/db/query/users.sql b/db/query/users.sql index 9bd3e73..ed26b7b 100644 --- a/db/query/users.sql +++ b/db/query/users.sql @@ -4,14 +4,14 @@ VALUES ($1, $2, $3, $4, $5, $6, $7, $8,$9) RETURNING *; -- name: GetUser :one -SELECT id, username, hashed_password, full_name, email, avatar,role, birth_date, password_changed_at, created_at +SELECT * FROM users WHERE username = $1; -- name: UpdateUser :one UPDATE users -SET username = $1, hashed_password = $2, full_name = $3, email = $4, avatar = $5, role=$6, birth_date = $7, password_changed_at = $8, created_at = $9 -WHERE id = $10 +SET username = $1, hashed_password = $2, full_name = $3, email = $4, avatar = $5, role=$6, birth_date = $7, is_email_verified=$8,password_changed_at = $9, created_at = $10 +WHERE id = $11 RETURNING *; -- name: DeleteUser :exec diff --git a/db/query/verify_email.sql b/db/query/verify_email.sql new file mode 100644 index 0000000..c173b57 --- /dev/null +++ b/db/query/verify_email.sql @@ -0,0 +1,20 @@ +-- name: CreateVerifyEmail :one +INSERT INTO verify_emails( + username, + email, + secret_code +)VALUES( + $1,$2,$3 +) RETURNING *; + + +-- name: UpdateVerifyEmail :one +UPDATE verify_emails +SET + is_used = TRUE +WHERE + id= @id + AND secret_code= @secret_code + AND is_used= FALSE + AND expired_at > now() +RETURNING *; diff --git a/db/sqlc/models.go b/db/sqlc/models.go index fb0dd33..d3db2fa 100644 --- a/db/sqlc/models.go +++ b/db/sqlc/models.go @@ -96,6 +96,7 @@ type User struct { Avatar pgtype.Text `json:"avatar"` Role string `json:"role"` BirthDate pgtype.Date `json:"birth_date"` + IsEmailVerified bool `json:"is_email_verified"` PasswordChangedAt time.Time `json:"password_changed_at"` CreatedAt time.Time `json:"created_at"` } @@ -109,3 +110,13 @@ type UserPost struct { UserID int32 `json:"user_id"` PostID int32 `json:"post_id"` } + +type VerifyEmail struct { + ID int32 `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + SecretCode string `json:"secret_code"` + IsUsed bool `json:"is_used"` + CreatedAt time.Time `json:"created_at"` + ExpiredAt time.Time `json:"expired_at"` +} diff --git a/db/sqlc/querier.go b/db/sqlc/querier.go index 0ceb15d..8443942 100644 --- a/db/sqlc/querier.go +++ b/db/sqlc/querier.go @@ -11,6 +11,7 @@ import ( ) type Querier interface { + CreateVerifyEmail(ctx context.Context, arg CreateVerifyEmailParams) (VerifyEmail, error) DeleteCategory(ctx context.Context, id int32) error DeleteComment(ctx context.Context, id int32) error DeleteFeaturedStory(ctx context.Context, id int32) error @@ -66,6 +67,7 @@ type Querier interface { UpdateSession(ctx context.Context, arg UpdateSessionParams) error UpdateTag(ctx context.Context, arg UpdateTagParams) (Tag, error) UpdateUser(ctx context.Context, arg UpdateUserParams) (User, error) + UpdateVerifyEmail(ctx context.Context, arg UpdateVerifyEmailParams) (VerifyEmail, error) } var _ Querier = (*Queries)(nil) diff --git a/db/sqlc/users.sql.go b/db/sqlc/users.sql.go index 27bec73..b4f5f07 100644 --- a/db/sqlc/users.sql.go +++ b/db/sqlc/users.sql.go @@ -23,7 +23,7 @@ func (q *Queries) DeleteUser(ctx context.Context, id int32) error { } const getUser = `-- name: GetUser :one -SELECT id, username, hashed_password, full_name, email, avatar,role, birth_date, password_changed_at, created_at +SELECT id, username, hashed_password, full_name, email, avatar, role, birth_date, is_email_verified, password_changed_at, created_at FROM users WHERE username = $1 ` @@ -40,6 +40,7 @@ func (q *Queries) GetUser(ctx context.Context, username string) (User, error) { &i.Avatar, &i.Role, &i.BirthDate, + &i.IsEmailVerified, &i.PasswordChangedAt, &i.CreatedAt, ) @@ -49,7 +50,7 @@ func (q *Queries) GetUser(ctx context.Context, username string) (User, error) { const insertUser = `-- name: InsertUser :one INSERT INTO users (username, hashed_password, full_name, email, avatar,role, birth_date, password_changed_at, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8,$9) -RETURNING id, username, hashed_password, full_name, email, avatar, role, birth_date, password_changed_at, created_at +RETURNING id, username, hashed_password, full_name, email, avatar, role, birth_date, is_email_verified, password_changed_at, created_at ` type InsertUserParams struct { @@ -86,6 +87,7 @@ func (q *Queries) InsertUser(ctx context.Context, arg InsertUserParams) (User, e &i.Avatar, &i.Role, &i.BirthDate, + &i.IsEmailVerified, &i.PasswordChangedAt, &i.CreatedAt, ) @@ -94,9 +96,9 @@ func (q *Queries) InsertUser(ctx context.Context, arg InsertUserParams) (User, e const updateUser = `-- name: UpdateUser :one UPDATE users -SET username = $1, hashed_password = $2, full_name = $3, email = $4, avatar = $5, role=$6, birth_date = $7, password_changed_at = $8, created_at = $9 -WHERE id = $10 -RETURNING id, username, hashed_password, full_name, email, avatar, role, birth_date, password_changed_at, created_at +SET username = $1, hashed_password = $2, full_name = $3, email = $4, avatar = $5, role=$6, birth_date = $7, is_email_verified=$8,password_changed_at = $9, created_at = $10 +WHERE id = $11 +RETURNING id, username, hashed_password, full_name, email, avatar, role, birth_date, is_email_verified, password_changed_at, created_at ` type UpdateUserParams struct { @@ -107,6 +109,7 @@ type UpdateUserParams struct { Avatar pgtype.Text `json:"avatar"` Role string `json:"role"` BirthDate pgtype.Date `json:"birth_date"` + IsEmailVerified bool `json:"is_email_verified"` PasswordChangedAt time.Time `json:"password_changed_at"` CreatedAt time.Time `json:"created_at"` ID int32 `json:"id"` @@ -121,6 +124,7 @@ func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) (User, e arg.Avatar, arg.Role, arg.BirthDate, + arg.IsEmailVerified, arg.PasswordChangedAt, arg.CreatedAt, arg.ID, @@ -135,6 +139,7 @@ func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) (User, e &i.Avatar, &i.Role, &i.BirthDate, + &i.IsEmailVerified, &i.PasswordChangedAt, &i.CreatedAt, ) diff --git a/db/sqlc/verify_email.sql.go b/db/sqlc/verify_email.sql.go new file mode 100644 index 0000000..d2ee91e --- /dev/null +++ b/db/sqlc/verify_email.sql.go @@ -0,0 +1,73 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.26.0 +// source: verify_email.sql + +package db + +import ( + "context" +) + +const createVerifyEmail = `-- name: CreateVerifyEmail :one +INSERT INTO verify_emails( + username, + email, + secret_code +)VALUES( + $1,$2,$3 +) RETURNING id, username, email, secret_code, is_used, created_at, expired_at +` + +type CreateVerifyEmailParams struct { + Username string `json:"username"` + Email string `json:"email"` + SecretCode string `json:"secret_code"` +} + +func (q *Queries) CreateVerifyEmail(ctx context.Context, arg CreateVerifyEmailParams) (VerifyEmail, error) { + row := q.db.QueryRow(ctx, createVerifyEmail, arg.Username, arg.Email, arg.SecretCode) + var i VerifyEmail + err := row.Scan( + &i.ID, + &i.Username, + &i.Email, + &i.SecretCode, + &i.IsUsed, + &i.CreatedAt, + &i.ExpiredAt, + ) + return i, err +} + +const updateVerifyEmail = `-- name: UpdateVerifyEmail :one +UPDATE verify_emails +SET + is_used = TRUE +WHERE + id= $1 + AND secret_code= $2 + AND is_used= FALSE + AND expired_at > now() +RETURNING id, username, email, secret_code, is_used, created_at, expired_at +` + +type UpdateVerifyEmailParams struct { + ID int32 `json:"id"` + SecretCode string `json:"secret_code"` +} + +func (q *Queries) UpdateVerifyEmail(ctx context.Context, arg UpdateVerifyEmailParams) (VerifyEmail, error) { + row := q.db.QueryRow(ctx, updateVerifyEmail, arg.ID, arg.SecretCode) + var i VerifyEmail + err := row.Scan( + &i.ID, + &i.Username, + &i.Email, + &i.SecretCode, + &i.IsUsed, + &i.CreatedAt, + &i.ExpiredAt, + ) + return i, err +} diff --git a/locales/assets/en.json b/locales/assets/en.json index aa1bcd2..aa4248f 100644 --- a/locales/assets/en.json +++ b/locales/assets/en.json @@ -20,6 +20,7 @@ "User":{ "RegisterSuccess":"User registered successfully!", + "VerifyEmail":"Please verify your email to login!", "LogoutSuccess":"Successfully logged out!" }, "Pagination":{ diff --git a/locales/localekeys.g.go b/locales/localekeys.g.go index e66907b..d7dc8a0 100644 --- a/locales/localekeys.g.go +++ b/locales/localekeys.g.go @@ -23,4 +23,5 @@ const ( Task_FailedDistribute = "Task.FailedDistribute" User_LogoutSuccess = "User.LogoutSuccess" User_RegisterSuccess = "User.RegisterSuccess" + User_VerifyEmail = "User.VerifyEmail" ) diff --git a/worker/task_send_verify_email.go b/worker/task_send_verify_email.go index cfeb2f7..b3e6535 100644 --- a/worker/task_send_verify_email.go +++ b/worker/task_send_verify_email.go @@ -6,6 +6,8 @@ import ( "fmt" "github.com/hibiken/asynq" + db "github.com/mustafayilmazdev/musarchive/db/sqlc" + "github.com/mustafayilmazdev/musarchive/util" "github.com/rs/zerolog/log" ) @@ -30,46 +32,46 @@ func (distributor *RedisTaskDistributor) DistributeTaskSendVerifyEmail(ctx conte } func (processor *RedisTaskProcessor) ProcessTaskSendVerifyEmail(ctx context.Context, task *asynq.Task) error { - // var payload PayloadSendVerifyEmail - // if err := json.Unmarshal(task.Payload(), &payload); err != nil { - // return fmt.Errorf("failed to unmarshal payload: %w", asynq.SkipRetry) - // } + var payload PayloadSendVerifyEmail + if err := json.Unmarshal(task.Payload(), &payload); err != nil { + return fmt.Errorf("failed to unmarshal payload: %w", asynq.SkipRetry) + } - // user, err := processor.store.GetUser(ctx, payload.Username) - // if err != nil { - // // if errors.Is(err, db.ErrRecordNotFound) { - // // return fmt.Errorf("user doesn't exist: %w", asynq.SkipRetry) - // // } - // return fmt.Errorf("failed to get user: %w", err) + user, err := processor.store.GetUser(ctx, payload.Username) + if err != nil { + // if errors.Is(err, db.ErrRecordNotFound) { + // return fmt.Errorf("user doesn't exist: %w", asynq.SkipRetry) + // } + return fmt.Errorf("failed to get user: %w", err) - // } + } - // verifyEmail, err := processor.store.CreateVerifyEmail(ctx, db.CreateVerifyEmailParams{ - // Username: payload.Username, - // Email: user.Email, - // SecretCode: util.RandomString(32), - // }) + verifyEmail, err := processor.store.CreateVerifyEmail(ctx, db.CreateVerifyEmailParams{ + Username: payload.Username, + Email: user.Email, + SecretCode: util.RandomString(32), + }) - // if err != nil { - // return fmt.Errorf("failed to create verify email: %w", err) - // } - // subject := "Welcome to Simple Bank" - // verifyUrl := fmt.Sprintf("http://localhost:8080/v1/verify_email?email_id=%d&secret_code=%s", verifyEmail.ID, verifyEmail.SecretCode) - // content := fmt.Sprintf(` - // Hello %s,
- // Thank you for registering with us!
- // Please click here to verify your email address.
- // `, user.FullName, verifyUrl) + if err != nil { + return fmt.Errorf("failed to create verify email: %w", err) + } + subject := "Welcome to Simple Bank" + verifyUrl := fmt.Sprintf("http://localhost:8080/v1/verify_email?email_id=%d&secret_code=%s", verifyEmail.ID, verifyEmail.SecretCode) + content := fmt.Sprintf(` + Hello %s,
+ Thank you for registering with us!
+ Please click here to verify your email address.
+ `, user.FullName, verifyUrl) - // to := []string{user.Email} + to := []string{user.Email} - // err = processor.mailer.SendEmail(subject, content, to, nil, nil, nil) + err = processor.mailer.SendEmail(subject, content, to, nil, nil, nil) - // if err != nil { - // return fmt.Errorf("failed to send verify email: %w", err) - // } + if err != nil { + return fmt.Errorf("failed to send verify email: %w", err) + } - // log.Info().Str("type", task.Type()).Bytes("payload", task.Payload()).Str("queue", user.Email).Msg("enqueued task") + log.Info().Str("type", task.Type()).Bytes("payload", task.Payload()).Str("queue", user.Email).Msg("enqueued task") return nil } From f069fcd97ccb0d750d40133729f53b91fb8f8ec2 Mon Sep 17 00:00:00 2001 From: codermuss Date: Sun, 4 Aug 2024 11:07:30 +0300 Subject: [PATCH 3/6] =?UTF-8?q?=E2=9C=A8=20edit=20localization=20manager?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/main_test.go | 2 +- locales/localization_manager.go | 16 ++++++++++------ main.go | 2 +- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/api/main_test.go b/api/main_test.go index 3150af1..a55ddac 100644 --- a/api/main_test.go +++ b/api/main_test.go @@ -21,7 +21,7 @@ func newTestServer(t *testing.T, store db.Store, taskDistributor worker.TaskDist } // Initialize the LocalizationManager singleton - if err := localization.Initialize("../" + util.LocalizationPath + util.DefaultLocale + util.LocalizationType); err != nil { + if err := localization.Initialize("../locales/assets/"); err != nil { log.Fatal().Msg("Can not load localization") } diff --git a/locales/localization_manager.go b/locales/localization_manager.go index a8f10b8..574d47a 100644 --- a/locales/localization_manager.go +++ b/locales/localization_manager.go @@ -6,6 +6,7 @@ import ( "sync" + "github.com/mustafayilmazdev/musarchive/util" "github.com/nicksnyder/go-i18n/v2/i18n" "golang.org/x/text/language" ) @@ -20,12 +21,12 @@ var ( once sync.Once initErr error - supportedLangs = []string{"en"} + supportedLangs = []string{"en", "tr"} defaultLang = "en" ) // Initialize initializes the LocalizationManager singleton -func Initialize(path string) error { +func Initialize(assetPath string) error { once.Do(func() { bundle := i18n.NewBundle(language.English) bundle.RegisterUnmarshalFunc("json", json.Unmarshal) @@ -36,7 +37,7 @@ func Initialize(path string) error { // Load all supported languages at startup for _, lang := range supportedLangs { - if err := instance.loadLanguageFiles(path, lang); err != nil { + if err := instance.loadLanguageFiles(assetPath + lang + util.LocalizationType); err != nil { initErr = fmt.Errorf("failed to load language files for %s: %v", lang, err) return } @@ -44,7 +45,7 @@ func Initialize(path string) error { } // Ensure the default language is loaded - if err := instance.loadLanguageFiles(path, defaultLang); err != nil { + if err := instance.loadLanguageFiles(assetPath + defaultLang + util.LocalizationType); err != nil { initErr = fmt.Errorf("failed to load default language files: %v", err) return } @@ -59,14 +60,17 @@ func GetInstance() *LocalizationManager { } // loadLanguageFiles loads the translation files for a given language -func (lm *LocalizationManager) loadLanguageFiles(path, lang string) error { +func (lm *LocalizationManager) loadLanguageFiles(path string) error { _, err := lm.bundle.LoadMessageFile(path) return err } // Translate retrieves the localized message for the given language and message ID func (lm *LocalizationManager) Translate(lang, messageID string, args ...interface{}) string { - localizer, _ := lm.localizers.Load(lang) + localizer, ok := lm.localizers.Load(lang) + if !ok { + localizer, _ = lm.localizers.Load(util.DefaultLocale) + } if loc, ok := localizer.(*i18n.Localizer); ok { params := make(map[string]interface{}) diff --git a/main.go b/main.go index cb69552..6228023 100644 --- a/main.go +++ b/main.go @@ -48,7 +48,7 @@ func main() { } // Initialize the LocalizationManager singleton - if err := localization.Initialize(util.LocalizationPath + util.DefaultLocale + util.LocalizationType); err != nil { + if err := localization.Initialize(util.LocalizationPath); err != nil { log.Fatal().Msg("Can not load localization") } From dc3aeca3d98579b799701d23bf1763a40f23d59f Mon Sep 17 00:00:00 2001 From: codermuss Date: Sun, 4 Aug 2024 11:08:03 +0300 Subject: [PATCH 4/6] =?UTF-8?q?=E2=9C=A8=20add=20user=20role=20to=20the=20?= =?UTF-8?q?login=20response?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/login.go | 1 + 1 file changed, 1 insertion(+) diff --git a/api/login.go b/api/login.go index bb0e827..c7e5279 100644 --- a/api/login.go +++ b/api/login.go @@ -33,6 +33,7 @@ func newUserResponse(user db.User) UserResponse { Username: user.Username, FullName: user.FullName, Email: user.Email, + Role: user.Role, Avatar: user.Avatar, BirthDate: user.BirthDate, PasswordChangedAt: user.PasswordChangedAt, From eb30bae618ca341c60c1a55bccd53cd7c5517658 Mon Sep 17 00:00:00 2001 From: codermuss Date: Sun, 4 Aug 2024 11:09:13 +0300 Subject: [PATCH 5/6] =?UTF-8?q?=E2=9C=A8=20complete=20email=20sending=20fl?= =?UTF-8?q?ow=20with=20bg=20task?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + api/register.go | 2 +- api/register_test.go | 49 ++++++++++------- api/server.go | 2 + api/verify_email.go | 55 +++++++++++++++++++ app.env | 6 ++- db/migration/000001_initial.down.sql | 4 +- db/mock/store.go | 15 ++++++ db/query/users.sql | 14 ++++- db/sqlc/post_tags_test.go | 1 - db/sqlc/store.go | 1 + db/sqlc/tx_verify_email.go | 46 ++++++++++++++++ db/sqlc/users.sql.go | 34 +++++++----- db/sqlc/users_test.go | 30 +++++++---- locales/assets/en.json | 12 ++++- locales/assets/tr.json | 47 ++++++++++++++++ locales/localekeys.g.go | 8 +++ templates/error_verify_email.html | 68 +++++++++++++++++++++++ templates/success_verify_email.html | 81 ++++++++++++++++++++++++++++ worker/task_send_verify_email.go | 13 +++-- 20 files changed, 432 insertions(+), 57 deletions(-) create mode 100644 .gitignore create mode 100644 api/verify_email.go create mode 100644 db/sqlc/tx_verify_email.go create mode 100644 locales/assets/tr.json create mode 100644 templates/error_verify_email.html create mode 100644 templates/success_verify_email.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ba698c7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.vscode/launch.json diff --git a/api/register.go b/api/register.go index 82f86b1..ea77e6e 100644 --- a/api/register.go +++ b/api/register.go @@ -77,7 +77,7 @@ func (server *Server) RegisterUser(ctx *gin.Context) { BirthDate: req.BirthDate, } argAfterCreate := func(user db.User) error { - taskPayload := &worker.PayloadSendVerifyEmail{Username: user.Username} + taskPayload := &worker.PayloadSendVerifyEmail{Username: user.Username, Locale: localeValue} opts := []asynq.Option{ asynq.MaxRetry(10), asynq.ProcessIn(10 * time.Second), diff --git a/api/register_test.go b/api/register_test.go index 6850887..89212f9 100644 --- a/api/register_test.go +++ b/api/register_test.go @@ -23,12 +23,12 @@ import ( // Custom matcher for comparing InsertUserParams with hashed password type eqCreateUserParamsMatcher struct { - arg db.InsertUserParams + arg db.RegisterUserTxParams password string } func (e eqCreateUserParamsMatcher) Matches(x interface{}) bool { - arg, ok := x.(db.InsertUserParams) + arg, ok := x.(db.RegisterUserTxParams) if !ok { return false } @@ -40,8 +40,8 @@ func (e eqCreateUserParamsMatcher) Matches(x interface{}) bool { } e.arg.HashedPassword = arg.HashedPassword - var leftValue db.InsertUserParams = e.arg - var rightValue db.InsertUserParams = arg + var leftValue db.InsertUserParams = e.arg.InsertUserParams + var rightValue db.InsertUserParams = arg.InsertUserParams result := reflect.DeepEqual(leftValue, rightValue) return result } @@ -51,7 +51,7 @@ func (e eqCreateUserParamsMatcher) String() string { } // Helper function to create the custom matcher -func EqCreateUserParams(arg db.InsertUserParams, password string) gomock.Matcher { +func EqCreateUserParams(arg db.RegisterUserTxParams, password string) gomock.Matcher { return eqCreateUserParamsMatcher{arg, password} } @@ -76,7 +76,7 @@ func TestCreateUserAPI(t *testing.T) { "birth_date": user.BirthDate, }, buildStubs: func(store *mockdb.MockStore, taskDistributor *mockwk.MockTaskDistributor) { - arg := db.InsertUserParams{ + argUser := db.InsertUserParams{ Username: user.Username, FullName: user.FullName, Email: user.Email, @@ -84,10 +84,18 @@ func TestCreateUserAPI(t *testing.T) { BirthDate: user.BirthDate, Role: user.Role, } - store.EXPECT(). - InsertUser(gomock.Any(), EqCreateUserParams(arg, password)). - Times(1). - Return(user, nil) + argAfterCreate := func(user db.User) error { + fmt.Println("after create triggered") + return nil + } + arg := db.RegisterUserTxParams{ + InsertUserParams: argUser, + AfterCreate: argAfterCreate, + } + store.EXPECT().RegisterUserTx(gomock.Any(), EqCreateUserParams(arg, password)).Times(1).Return(db.RegisterUserTxResult{ + User: user, + Profile: db.Profile{}, + }, nil) require.Equal(t, user.Role, util.Standard) }, checkResponse: func(recorder *httptest.ResponseRecorder) { @@ -105,9 +113,9 @@ func TestCreateUserAPI(t *testing.T) { }, buildStubs: func(store *mockdb.MockStore, taskDistributor *mockwk.MockTaskDistributor) { store.EXPECT(). - InsertUser(gomock.Any(), gomock.Any()). + RegisterUserTx(gomock.Any(), gomock.Any()). Times(1). - Return(db.User{}, sql.ErrConnDone) + Return(db.RegisterUserTxResult{}, sql.ErrConnDone) }, checkResponse: func(recorder *httptest.ResponseRecorder) { require.Equal(t, http.StatusInternalServerError, recorder.Code) @@ -140,9 +148,9 @@ func TestCreateUserAPI(t *testing.T) { }, buildStubs: func(store *mockdb.MockStore, taskDistributor *mockwk.MockTaskDistributor) { store.EXPECT(). - InsertUser(gomock.Any(), gomock.Any()). + RegisterUserTx(gomock.Any(), gomock.Any()). Times(1). - Return(db.User{}, db.ErrUniqueViolation) + Return(db.RegisterUserTxResult{}, db.ErrUniqueViolation) }, checkResponse: func(recorder *httptest.ResponseRecorder) { require.Equal(t, http.StatusForbidden, recorder.Code) @@ -233,6 +241,7 @@ func RandomUser(t *testing.T) (user db.User, password string) { Valid: true, Time: util.DateFixed(), }, + IsEmailVerified: true, PasswordChangedAt: util.DateFixed(), CreatedAt: util.DateFixed(), } @@ -253,13 +262,13 @@ func requireBodyMatchUser(t *testing.T, body *bytes.Buffer, expectedUser db.User require.NoError(t, err) // Unmarshal the Data field into a db.User - var gotUser db.User - err = json.Unmarshal(dataBytes, &gotUser) + var registerUserTxResult db.RegisterUserTxResult + err = json.Unmarshal(dataBytes, ®isterUserTxResult) require.NoError(t, err) // Compare the expected and actual user fields - require.Equal(t, expectedUser.Username, gotUser.Username) - require.Equal(t, expectedUser.FullName, gotUser.FullName) - require.Equal(t, expectedUser.Email, gotUser.Email) - require.Empty(t, gotUser.HashedPassword) + require.Equal(t, expectedUser.Username, registerUserTxResult.User.Username) + require.Equal(t, expectedUser.FullName, registerUserTxResult.User.FullName) + require.Equal(t, expectedUser.Email, registerUserTxResult.User.Email) + require.Empty(t, registerUserTxResult.User.HashedPassword) } diff --git a/api/server.go b/api/server.go index 4165aae..2709bc5 100644 --- a/api/server.go +++ b/api/server.go @@ -40,6 +40,7 @@ func (server *Server) setupRouter() { router.Use(ZerologMiddleware()) router.Use(gin.Recovery()) + router.LoadHTMLGlob("/Users/mustafayilmaz/Go/projects/musarchive/templates/*") // Serve the API endpoints api := router.Group("/v1") @@ -53,6 +54,7 @@ func (server *Server) setupRouter() { { authRoutes.POST("/register", server.RegisterUser) authRoutes.POST("/login", server.LoginUser) + authRoutes.GET("/verify_email", server.VerifyEmail) } // Posts routes (protected by auth middleware) diff --git a/api/verify_email.go b/api/verify_email.go new file mode 100644 index 0000000..7749280 --- /dev/null +++ b/api/verify_email.go @@ -0,0 +1,55 @@ +package api + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" + db "github.com/mustafayilmazdev/musarchive/db/sqlc" + localization "github.com/mustafayilmazdev/musarchive/locales" +) + +type VerifyEmailRequest struct { + EmailID int32 `form:"email_id" binding:"required"` + SecretCode string `form:"secret_code" binding:"required"` + Locale string `form:"locale" binding:"required"` +} + +func (server *Server) VerifyEmail(ctx *gin.Context) { + var req VerifyEmailRequest + + if err := ctx.ShouldBindQuery(&req); err != nil { + BuildResponse(ctx, BaseResponse{ + Code: http.StatusBadRequest, + Message: ResponseMessage{ + Type: ERROR, + Content: err.Error(), + }, + }) + return + } + _, err := server.store.VerifyEmailTx(ctx, db.VerifyEmailTxParams{ + EmailID: req.EmailID, + SecretCode: req.SecretCode, + }) + + if err != nil { + data := gin.H{ + "Title": server.lm.Translate(req.Locale, localization.User_VerifyEmailErrorTitle), + "Timestamp": time.Now().Format(time.RFC1123), + "Content": server.lm.Translate(req.Locale, localization.Errors_AnErrorOccured), + "ErrorMessage": err, + } + ctx.HTML(http.StatusOK, "error_verify_email.html", data) + return + } + + data := gin.H{ + "Title": server.lm.Translate(req.Locale, localization.User_VerifyEmailSuccessTitle), + "Timestamp": time.Now().Format(time.RFC1123), + "Content": server.lm.Translate(req.Locale, localization.User_VerifyEmailSuccess), + "WelcomeMessage": server.lm.Translate(req.Locale, localization.User_VerifyEmailMessage), + } + ctx.HTML(http.StatusOK, "success_verify_email.html", data) + +} diff --git a/app.env b/app.env index 086c84f..14fdabc 100644 --- a/app.env +++ b/app.env @@ -4,4 +4,8 @@ HTTP_SERVER_ADDRESS=0.0.0.0:8080 TOKEN_SYMMETRIC_KEY=12345678901234567890123456789012 MIGRATION_URL= file://db/migration ACCESS_TOKEN_DURATION=15m -REFRESH_TOKEN_DURATION=24h \ No newline at end of file +REFRESH_TOKEN_DURATION=24h +EMAIL_SENDER_NAME= Pipe +EMAIL_SENDER_ADDRESS= mussbank@gmail.com +EMAIL_SENDER_PASSWORD= +REDIS_ADDRESS=0.0.0.0:6379 \ No newline at end of file diff --git a/db/migration/000001_initial.down.sql b/db/migration/000001_initial.down.sql index 53d13de..79733c4 100644 --- a/db/migration/000001_initial.down.sql +++ b/db/migration/000001_initial.down.sql @@ -15,7 +15,7 @@ ALTER TABLE "post_categories" DROP CONSTRAINT "post_categories_post_id_fkey"; ALTER TABLE "post_tags" DROP CONSTRAINT "post_tags_tag_id_fkey"; ALTER TABLE "post_tags" DROP CONSTRAINT "post_tags_post_id_fkey"; ALTER TABLE "posts" DROP CONSTRAINT "posts_user_id_fkey"; -ALTER TABLE "verify_emails" DROP CONSTRAINT "verify_emails_user_id_fkey"; +ALTER TABLE "verify_emails" DROP CONSTRAINT "verify_emails_username_fkey"; -- Drop tables in reverse order of creation DROP TABLE IF EXISTS "sessions"; @@ -32,4 +32,4 @@ DROP TABLE IF EXISTS "posts"; DROP TABLE IF EXISTS "categories"; DROP TABLE IF EXISTS "users"; DROP TABLE IF EXISTS "onboarding"; -DROP TABLE IF EXISTS "verify_emails"; \ No newline at end of file +DROP TABLE IF EXISTS "verify_emails" CASCADE; \ No newline at end of file diff --git a/db/mock/store.go b/db/mock/store.go index e8f1b46..03db5a9 100644 --- a/db/mock/store.go +++ b/db/mock/store.go @@ -891,3 +891,18 @@ func (mr *MockStoreMockRecorder) UpdateVerifyEmail(arg0, arg1 interface{}) *gomo mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateVerifyEmail", reflect.TypeOf((*MockStore)(nil).UpdateVerifyEmail), arg0, arg1) } + +// VerifyEmailTx mocks base method. +func (m *MockStore) VerifyEmailTx(arg0 context.Context, arg1 db.VerifyEmailTxParams) (db.VerifyEmailTxResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "VerifyEmailTx", arg0, arg1) + ret0, _ := ret[0].(db.VerifyEmailTxResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// VerifyEmailTx indicates an expected call of VerifyEmailTx. +func (mr *MockStoreMockRecorder) VerifyEmailTx(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "VerifyEmailTx", reflect.TypeOf((*MockStore)(nil).VerifyEmailTx), arg0, arg1) +} diff --git a/db/query/users.sql b/db/query/users.sql index ed26b7b..6f51bf5 100644 --- a/db/query/users.sql +++ b/db/query/users.sql @@ -10,8 +10,18 @@ WHERE username = $1; -- name: UpdateUser :one UPDATE users -SET username = $1, hashed_password = $2, full_name = $3, email = $4, avatar = $5, role=$6, birth_date = $7, is_email_verified=$8,password_changed_at = $9, created_at = $10 -WHERE id = $11 +SET + username = COALESCE(sqlc.narg(username), username), + hashed_password = COALESCE(sqlc.narg(hashed_password), hashed_password), + full_name = COALESCE(sqlc.narg(full_name), full_name), + email = COALESCE(sqlc.narg(email), email), + avatar = COALESCE(sqlc.narg(avatar), avatar), + role = COALESCE(sqlc.narg(role), role), + birth_date = COALESCE(sqlc.narg(birth_date), birth_date), + is_email_verified = COALESCE(sqlc.narg(is_email_verified), is_email_verified), + password_changed_at = COALESCE(sqlc.narg(password_changed_at), password_changed_at), + created_at = COALESCE(sqlc.narg(created_at), created_at) +WHERE id = sqlc.arg(id) RETURNING *; -- name: DeleteUser :exec diff --git a/db/sqlc/post_tags_test.go b/db/sqlc/post_tags_test.go index 68da8d1..d09e7f8 100644 --- a/db/sqlc/post_tags_test.go +++ b/db/sqlc/post_tags_test.go @@ -41,7 +41,6 @@ func TestDeletePostTag(t *testing.T) { tags, err := testStore.GetTagsForPost(context.Background(), randomPostTag[0].PostID) require.NoError(t, err) require.NotEmpty(t, tags) - fmt.Println(tags) err = testStore.DeletePostTag(context.Background(), DeletePostTagParams{ PostID: randomPostTag[0].PostID, TagID: tags[len(tags)-1].ID, diff --git a/db/sqlc/store.go b/db/sqlc/store.go index 152813c..315caaa 100644 --- a/db/sqlc/store.go +++ b/db/sqlc/store.go @@ -9,6 +9,7 @@ import ( type Store interface { Querier RegisterUserTx(ctx context.Context, arg RegisterUserTxParams) (RegisterUserTxResult, error) + VerifyEmailTx(ctx context.Context, arg VerifyEmailTxParams) (VerifyEmailTxResult, error) } type SQLStore struct { diff --git a/db/sqlc/tx_verify_email.go b/db/sqlc/tx_verify_email.go new file mode 100644 index 0000000..c2b03e5 --- /dev/null +++ b/db/sqlc/tx_verify_email.go @@ -0,0 +1,46 @@ +package db + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +type VerifyEmailTxParams struct { + EmailID int32 + SecretCode string +} +type VerifyEmailTxResult struct { + User User + VerifyEmail VerifyEmail +} + +// * Note [codermuss]: This method responsible with creating user. +// * Note [codermuss]: It uses execTx to handle DB Transaction error +func (store *SQLStore) VerifyEmailTx(ctx context.Context, arg VerifyEmailTxParams) (VerifyEmailTxResult, error) { + var result VerifyEmailTxResult + + err := store.execTx(ctx, func(q *Queries) error { + var err error + result.VerifyEmail, err = q.UpdateVerifyEmail(ctx, UpdateVerifyEmailParams{ + ID: arg.EmailID, + SecretCode: arg.SecretCode, + }) + if err != nil { + return err + } + user, err := q.GetUser(ctx, result.VerifyEmail.Username) + if err != nil { + return err + } + result.User, err = q.UpdateUser(ctx, UpdateUserParams{ + ID: user.ID, + IsEmailVerified: pgtype.Bool{ + Valid: true, + Bool: true, + }, + }) + return err + }) + return result, err +} diff --git a/db/sqlc/users.sql.go b/db/sqlc/users.sql.go index b4f5f07..bf0d1e3 100644 --- a/db/sqlc/users.sql.go +++ b/db/sqlc/users.sql.go @@ -96,23 +96,33 @@ func (q *Queries) InsertUser(ctx context.Context, arg InsertUserParams) (User, e const updateUser = `-- name: UpdateUser :one UPDATE users -SET username = $1, hashed_password = $2, full_name = $3, email = $4, avatar = $5, role=$6, birth_date = $7, is_email_verified=$8,password_changed_at = $9, created_at = $10 +SET + username = COALESCE($1, username), + hashed_password = COALESCE($2, hashed_password), + full_name = COALESCE($3, full_name), + email = COALESCE($4, email), + avatar = COALESCE($5, avatar), + role = COALESCE($6, role), + birth_date = COALESCE($7, birth_date), + is_email_verified = COALESCE($8, is_email_verified), + password_changed_at = COALESCE($9, password_changed_at), + created_at = COALESCE($10, created_at) WHERE id = $11 RETURNING id, username, hashed_password, full_name, email, avatar, role, birth_date, is_email_verified, password_changed_at, created_at ` type UpdateUserParams struct { - Username string `json:"username"` - HashedPassword string `json:"hashed_password"` - FullName string `json:"full_name"` - Email string `json:"email"` - Avatar pgtype.Text `json:"avatar"` - Role string `json:"role"` - BirthDate pgtype.Date `json:"birth_date"` - IsEmailVerified bool `json:"is_email_verified"` - PasswordChangedAt time.Time `json:"password_changed_at"` - CreatedAt time.Time `json:"created_at"` - ID int32 `json:"id"` + Username pgtype.Text `json:"username"` + HashedPassword pgtype.Text `json:"hashed_password"` + FullName pgtype.Text `json:"full_name"` + Email pgtype.Text `json:"email"` + Avatar pgtype.Text `json:"avatar"` + Role pgtype.Text `json:"role"` + BirthDate pgtype.Date `json:"birth_date"` + IsEmailVerified pgtype.Bool `json:"is_email_verified"` + PasswordChangedAt pgtype.Timestamptz `json:"password_changed_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + ID int32 `json:"id"` } func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) (User, error) { diff --git a/db/sqlc/users_test.go b/db/sqlc/users_test.go index 94aaf66..4df9d0d 100644 --- a/db/sqlc/users_test.go +++ b/db/sqlc/users_test.go @@ -79,11 +79,23 @@ func TestGetUser(t *testing.T) { func TestUpdateUser(t *testing.T) { randomUser := createRandomUser(t) updateUser := UpdateUserParams{ - ID: randomUser.ID, - Username: util.RandomUsername(), - HashedPassword: util.RandomString(10), - FullName: util.RandomString(10), - Email: util.RandomEmail(), + ID: randomUser.ID, + Username: pgtype.Text{ + Valid: true, + String: util.RandomUsername(), + }, + HashedPassword: pgtype.Text{ + Valid: true, + String: util.RandomString(10), + }, + FullName: pgtype.Text{ + Valid: true, + String: util.RandomString(10), + }, + Email: pgtype.Text{ + Valid: true, + String: util.RandomEmail(), + }, Avatar: pgtype.Text{ Valid: true, String: util.RandomImage(), @@ -99,10 +111,10 @@ func TestUpdateUser(t *testing.T) { require.NoError(t, err) require.NotEmpty(t, user) require.Equal(t, randomUser.ID, user.ID) - require.Equal(t, updateUser.Username, user.Username) - require.Equal(t, updateUser.FullName, user.FullName) - require.Equal(t, updateUser.Email, user.Email) - require.Equal(t, updateUser.HashedPassword, user.HashedPassword) + require.Equal(t, updateUser.Username.String, user.Username) + require.Equal(t, updateUser.FullName.String, user.FullName) + require.Equal(t, updateUser.Email.String, user.Email) + require.Equal(t, updateUser.HashedPassword.String, user.HashedPassword) require.Equal(t, updateUser.Avatar, user.Avatar) } diff --git a/locales/assets/en.json b/locales/assets/en.json index aa4248f..3cdc4e8 100644 --- a/locales/assets/en.json +++ b/locales/assets/en.json @@ -7,7 +7,8 @@ "MigrateUp":"Failed to run migrate up", "HttpGateway":"HTTP gateway server failed to server", "HttpGatewayShutdown":"Failed to shutdown HTTP gateway server", - "InternalError":"Internal error: {{.arg0}}" + "InternalError":"Internal error: {{.arg0}}", + "AnErrorOccured":"An error occurred while processing your request." }, "Success":{ "Migrate":"DB migrated successfully" @@ -21,7 +22,14 @@ "User":{ "RegisterSuccess":"User registered successfully!", "VerifyEmail":"Please verify your email to login!", - "LogoutSuccess":"Successfully logged out!" + "VerifiedEmail":"Email has been verified successfully!", + "LogoutSuccess":"Successfully logged out!", + "EmailTitle":"Welcome to Musarchive", + "EmailContent":"Hello {{.arg0}},
Thank you for registering with us!
Please click here to verify your email address.
", + "VerifyEmailMessage": "Welcome! We're excited to have you on board. You can now enjoy all the features of our platform. If you encounter any issues, feel free to reach out to our support team.", + "VerifyEmailSuccess":"Your email has been verified successfully!", + "VerifyEmailSuccessTitle":"Success!", + "VerifyEmailErrorTitle":"Error!" }, "Pagination":{ "pageError":"Invalid page number: {{.arg0}}", diff --git a/locales/assets/tr.json b/locales/assets/tr.json new file mode 100644 index 0000000..1a902cd --- /dev/null +++ b/locales/assets/tr.json @@ -0,0 +1,47 @@ +{ + "Errors": { + "CanNotConnectToDb": "Veritabanına bağlanılamıyor: {{.arg0}}", + "ErrorFromWaitGroup": "Bekleme grubundan hata", + "MigrationInstance": "Yeni göç örneği oluşturulamadı", + "MigrateUp": "Göç işlemi yapılamadı", + "HttpGateway": "HTTP geçiş sunucusu hizmet veremedi", + "HttpGatewayShutdown": "HTTP geçiş sunucusunu kapatmak başarısız oldu", + "InternalError": "İç hata: {{.arg0}}", + "AnErrorOccured": "İsteğiniz işlenirken bir hata oluştu." + }, + "Success": { + "Migrate": "Veritabanı başarıyla göç edildi" + }, + "Info": { + "StartHttp": "HTTP geçiş sunucusu {{.arg0}} adresinde başlatıldı", + "GracefulShutdown": "HTTP geçiş sunucusu düzgün şekilde kapatıldı", + "StopHttp": "HTTP geçiş sunucusu durduruldu" + }, + "User": { + "RegisterSuccess": "Kullanıcı başarıyla kaydedildi!", + "VerifyEmail": "Giriş yapmak için e-posta adresinizi doğrulayın!", + "VerifiedEmail": "E-posta başarıyla doğrulandı!", + "LogoutSuccess": "Başarıyla çıkış yapıldı!", + "EmailTitle": "Musarchive'ye Hoşgeldiniz", + "EmailContent": "Merhaba {{.arg0}},
Bizimle kayıt olduğunuz için teşekkür ederiz!
Lütfen e-posta adresinizi doğrulamak için buraya tıklayın.
", + "VerifyEmailMessage": "Hoş geldiniz! Aramızda olduğunuz için çok heyecanlıyız. Artık platformumuzun tüm özelliklerinden yararlanabilirsiniz. Herhangi bir sorunla karşılaşırsanız, destek ekibimizle iletişime geçmekten çekinmeyin.", + "VerifyEmailSuccess": "E-posta adresiniz başarıyla doğrulandı!", + "VerifyEmailSuccessTitle": "Başarılı!", + "VerifyEmailErrorTitle": "Hata!" + }, + "Pagination": { + "pageError": "Geçersiz sayfa numarası: {{.arg0}}", + "sizeError": "Geçersiz boyut numarası: {{.arg0}}" + }, + "Middleware": { + "UnsupportedAuthorization": "Desteklenmeyen yetkilendirme türü {{.arg0}}", + "InvalidAuthorization": "Geçersiz yetkilendirme başlığı formatı", + "HeaderIsNotProvided": "Yetkilendirme başlığı sağlanmadı" + }, + "Post": { + "InsertSuccess": "Gönderi başarıyla oluşturuldu!" + }, + "Task": { + "FailedDistribute": "Doğrulama e-postası gönderme görevini dağıtmak başarısız oldu: {{.arg0}}" + } +} \ No newline at end of file diff --git a/locales/localekeys.g.go b/locales/localekeys.g.go index d7dc8a0..6f987d1 100644 --- a/locales/localekeys.g.go +++ b/locales/localekeys.g.go @@ -3,6 +3,7 @@ package localization // DO NOT EDIT: This file is automatically generated by the musale CLI tool. const ( + Errors_AnErrorOccured = "Errors.AnErrorOccured" Errors_CanNotConnectToDb = "Errors.CanNotConnectToDb" Errors_ErrorFromWaitGroup = "Errors.ErrorFromWaitGroup" Errors_HttpGateway = "Errors.HttpGateway" @@ -21,7 +22,14 @@ const ( Post_InsertSuccess = "Post.InsertSuccess" Success_Migrate = "Success.Migrate" Task_FailedDistribute = "Task.FailedDistribute" + User_EmailContent = "User.EmailContent" + User_EmailTitle = "User.EmailTitle" User_LogoutSuccess = "User.LogoutSuccess" User_RegisterSuccess = "User.RegisterSuccess" + User_VerifiedEmail = "User.VerifiedEmail" User_VerifyEmail = "User.VerifyEmail" + User_VerifyEmailErrorTitle = "User.VerifyEmailErrorTitle" + User_VerifyEmailMessage = "User.VerifyEmailMessage" + User_VerifyEmailSuccess = "User.VerifyEmailSuccess" + User_VerifyEmailSuccessTitle = "User.VerifyEmailSuccessTitle" ) diff --git a/templates/error_verify_email.html b/templates/error_verify_email.html new file mode 100644 index 0000000..1135484 --- /dev/null +++ b/templates/error_verify_email.html @@ -0,0 +1,68 @@ + + + + + + {{.Title}} + + + +
+
🚫
+

{{.Title}}

+
{{.Timestamp}}
+

{{.Content}}

+ {{if .ErrorMessage}} +
{{.ErrorMessage}}
+ {{end}} +
+ + \ No newline at end of file diff --git a/templates/success_verify_email.html b/templates/success_verify_email.html new file mode 100644 index 0000000..de9516a --- /dev/null +++ b/templates/success_verify_email.html @@ -0,0 +1,81 @@ + + + + + + {{.Title}} + + + +
+
🎉
+

{{.Title}}

+

{{.Content}}

+
{{.Timestamp}}
+
{{.WelcomeMessage}}
+
+ + \ No newline at end of file diff --git a/worker/task_send_verify_email.go b/worker/task_send_verify_email.go index b3e6535..feedb8b 100644 --- a/worker/task_send_verify_email.go +++ b/worker/task_send_verify_email.go @@ -7,6 +7,7 @@ import ( "github.com/hibiken/asynq" db "github.com/mustafayilmazdev/musarchive/db/sqlc" + localization "github.com/mustafayilmazdev/musarchive/locales" "github.com/mustafayilmazdev/musarchive/util" "github.com/rs/zerolog/log" ) @@ -15,6 +16,7 @@ const TaskSendVerifyEmail = "task:send_verify_email" type PayloadSendVerifyEmail struct { Username string `json:"username"` + Locale string `json:"locale"` } func (distributor *RedisTaskDistributor) DistributeTaskSendVerifyEmail(ctx context.Context, payload *PayloadSendVerifyEmail, opts ...asynq.Option) error { @@ -55,13 +57,10 @@ func (processor *RedisTaskProcessor) ProcessTaskSendVerifyEmail(ctx context.Cont if err != nil { return fmt.Errorf("failed to create verify email: %w", err) } - subject := "Welcome to Simple Bank" - verifyUrl := fmt.Sprintf("http://localhost:8080/v1/verify_email?email_id=%d&secret_code=%s", verifyEmail.ID, verifyEmail.SecretCode) - content := fmt.Sprintf(` - Hello %s,
- Thank you for registering with us!
- Please click here to verify your email address.
- `, user.FullName, verifyUrl) + lm := localization.GetInstance() + subject := lm.Translate(payload.Locale, localization.User_EmailTitle) + verifyUrl := fmt.Sprintf("http://localhost:8080/v1/auth/verify_email?email_id=%d&secret_code=%s&locale=%s", verifyEmail.ID, verifyEmail.SecretCode, payload.Locale) + content := lm.Translate(payload.Locale, localization.User_EmailContent, user.FullName, verifyUrl) to := []string{user.Email} From d801092c7bd7e56905f47e74726f3bcbb344f7ba Mon Sep 17 00:00:00 2001 From: codermuss Date: Sun, 4 Aug 2024 11:35:44 +0300 Subject: [PATCH 6/6] =?UTF-8?q?=E2=9C=A8=20remove=20html=20flows=20on=20ve?= =?UTF-8?q?rify=20email?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/server.go | 1 - api/verify_email.go | 30 +++++++++++++++--------------- 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/api/server.go b/api/server.go index 2709bc5..2f93e5a 100644 --- a/api/server.go +++ b/api/server.go @@ -40,7 +40,6 @@ func (server *Server) setupRouter() { router.Use(ZerologMiddleware()) router.Use(gin.Recovery()) - router.LoadHTMLGlob("/Users/mustafayilmaz/Go/projects/musarchive/templates/*") // Serve the API endpoints api := router.Group("/v1") diff --git a/api/verify_email.go b/api/verify_email.go index 7749280..c3067c3 100644 --- a/api/verify_email.go +++ b/api/verify_email.go @@ -2,7 +2,6 @@ package api import ( "net/http" - "time" "github.com/gin-gonic/gin" db "github.com/mustafayilmazdev/musarchive/db/sqlc" @@ -34,22 +33,23 @@ func (server *Server) VerifyEmail(ctx *gin.Context) { }) if err != nil { - data := gin.H{ - "Title": server.lm.Translate(req.Locale, localization.User_VerifyEmailErrorTitle), - "Timestamp": time.Now().Format(time.RFC1123), - "Content": server.lm.Translate(req.Locale, localization.Errors_AnErrorOccured), - "ErrorMessage": err, - } - ctx.HTML(http.StatusOK, "error_verify_email.html", data) + BuildResponse(ctx, BaseResponse{ + Code: http.StatusInternalServerError, + Message: ResponseMessage{ + Type: ERROR, + Content: err.Error(), + }, + }) + return } - data := gin.H{ - "Title": server.lm.Translate(req.Locale, localization.User_VerifyEmailSuccessTitle), - "Timestamp": time.Now().Format(time.RFC1123), - "Content": server.lm.Translate(req.Locale, localization.User_VerifyEmailSuccess), - "WelcomeMessage": server.lm.Translate(req.Locale, localization.User_VerifyEmailMessage), - } - ctx.HTML(http.StatusOK, "success_verify_email.html", data) + BuildResponse(ctx, BaseResponse{ + Code: http.StatusOK, + Message: ResponseMessage{ + Type: SUCCESS, + Content: server.lm.Translate(req.Locale, localization.User_VerifyEmailMessage), + }, + }) }