diff --git a/cmd/main.go b/cmd/main.go index 4e9eed3..7c4daa3 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -21,8 +21,7 @@ import ( // mongologger "github.com/SamuelTariku/FortuneBet-Backend/internal/logger/mongoLogger" // "github.com/SamuelTariku/FortuneBet-Backend/internal/logger/mongoLogger" - mockemail "github.com/SamuelTariku/FortuneBet-Backend/internal/mocks/mock_email" - mocksms "github.com/SamuelTariku/FortuneBet-Backend/internal/mocks/mock_sms" + "github.com/SamuelTariku/FortuneBet-Backend/internal/repository" // "github.com/SamuelTariku/FortuneBet-Backend/internal/router" @@ -113,10 +112,8 @@ func main() { v := customvalidator.NewCustomValidator(validator.New()) authSvc := authentication.NewService(store, store, cfg.RefreshExpiry) - mockSms := mocksms.NewMockSMS() - mockEmail := mockemail.NewMockEmail() - userSvc := user.NewService(store, store, mockSms, mockEmail) + userSvc := user.NewService(store, store, cfg) eventSvc := event.New(cfg.Bet365Token, store) oddsSvc := odds.New(store, cfg, logger) diff --git a/go.mod b/go.mod index 1c0137f..5dbe980 100644 --- a/go.mod +++ b/go.mod @@ -76,4 +76,7 @@ require ( go.uber.org/zap v1.27.0 ) -require go.uber.org/multierr v1.10.0 // indirect +require ( + github.com/resend/resend-go/v2 v2.20.0 // indirect + go.uber.org/multierr v1.10.0 // indirect +) diff --git a/go.sum b/go.sum index de410d7..7e67dd4 100644 --- a/go.sum +++ b/go.sum @@ -110,6 +110,8 @@ github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT9 github.com/otiai10/mint v1.3.3/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/resend/resend-go/v2 v2.20.0 h1:MrIrgV0aHhwRgmcRPw33Nexn6aGJvCvG2XwfFpAMBGM= +github.com/resend/resend-go/v2 v2.20.0/go.mod h1:3YCb8c8+pLiqhtRFXTyFwlLvfjQtluxOr9HEh2BwCkQ= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= diff --git a/internal/config/config.go b/internal/config/config.go index db606c8..edcc62f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -13,23 +13,25 @@ import ( ) var ( - ErrInvalidDbUrl = errors.New("db url is invalid") - ErrInvalidPort = errors.New("port number is invalid") - ErrRefreshExpiry = errors.New("refresh token expiry is invalid") - ErrAccessExpiry = errors.New("access token expiry is invalid") - ErrInvalidJwtKey = errors.New("jwt key is invalid") - ErrLogLevel = errors.New("log level not set") - ErrInvalidLevel = errors.New("invalid log level") - ErrInvalidEnv = errors.New("env not set or invalid") - ErrInvalidSMSAPIKey = errors.New("SMS API key is invalid") - ErrMissingBetToken = errors.New("missing BET365_TOKEN in .env") - ErrInvalidPopOKClientID = errors.New("PopOK client ID is invalid") - ErrInvalidPopOKSecretKey = errors.New("PopOK secret key is invalid") - ErrInvalidPopOKBaseURL = errors.New("PopOK base URL is invalid") - ErrInvalidPopOKCallbackURL = errors.New("PopOK callback URL is invalid") - ErrInvalidVeliAPIURL = errors.New("Veli API URL is invalid") - ErrInvalidVeliOperatorKey = errors.New("Veli operator key is invalid") - ErrInvalidVeliSecretKey = errors.New("Veli secret key is invalid") + ErrInvalidDbUrl = errors.New("db url is invalid") + ErrInvalidPort = errors.New("port number is invalid") + ErrRefreshExpiry = errors.New("refresh token expiry is invalid") + ErrAccessExpiry = errors.New("access token expiry is invalid") + ErrInvalidJwtKey = errors.New("jwt key is invalid") + ErrLogLevel = errors.New("log level not set") + ErrInvalidLevel = errors.New("invalid log level") + ErrInvalidEnv = errors.New("env not set or invalid") + ErrInvalidSMSAPIKey = errors.New("SMS API key is invalid") + ErrMissingBetToken = errors.New("missing BET365_TOKEN in .env") + ErrInvalidPopOKClientID = errors.New("PopOK client ID is invalid") + ErrInvalidPopOKSecretKey = errors.New("PopOK secret key is invalid") + ErrInvalidPopOKBaseURL = errors.New("PopOK base URL is invalid") + ErrInvalidPopOKCallbackURL = errors.New("PopOK callback URL is invalid") + ErrInvalidVeliAPIURL = errors.New("Veli API URL is invalid") + ErrInvalidVeliOperatorKey = errors.New("Veli operator key is invalid") + ErrInvalidVeliSecretKey = errors.New("Veli secret key is invalid") + ErrMissingResendApiKey = errors.New("missing Resend Api key") + ErrMissingResendSenderEmail = errors.New("missing Resend sender name") ) type AleaPlayConfig struct { @@ -75,6 +77,8 @@ type Config struct { PopOK domain.PopOKConfig AleaPlay AleaPlayConfig `mapstructure:"alea_play"` VeliGames VeliGamesConfig `mapstructure:"veli_games"` + ResendApiKey string + ResendSenderEmail string } func NewConfig() (*Config, error) { @@ -287,6 +291,19 @@ func (c *Config) loadEnv() error { return ErrMissingBetToken } c.Bet365Token = betToken + + resendApiKey := os.Getenv("RESEND_API_KEY") + if resendApiKey == "" { + return ErrMissingResendApiKey + } + c.ResendApiKey = resendApiKey + + resendSenderEmail := os.Getenv("RESEND_SENDER_EMAIL") + if resendSenderEmail == "" { + return ErrMissingResendSenderEmail + } + c.ResendSenderEmail = resendSenderEmail + return nil } diff --git a/internal/pkgs/helpers/helpers.go b/internal/pkgs/helpers/helpers.go index 8c6645e..589375b 100644 --- a/internal/pkgs/helpers/helpers.go +++ b/internal/pkgs/helpers/helpers.go @@ -1,7 +1,17 @@ package helpers -import "github.com/google/uuid" +import ( + "fmt" + "math/rand/v2" + + "github.com/google/uuid" +) func GenerateID() string { return uuid.New().String() } + +func GenerateOTP() string { + num := 100000 + rand.UintN(899999) + return fmt.Sprintf("%d", num) // 6 digit random number [100,000 - 999,999] +} diff --git a/internal/services/authentication/impl.go b/internal/services/authentication/impl.go index e83fc7c..2760a63 100644 --- a/internal/services/authentication/impl.go +++ b/internal/services/authentication/impl.go @@ -40,7 +40,7 @@ func (s *Service) Login(ctx context.Context, email, phone string, password strin } oldRefreshToken, err := s.tokenStore.GetRefreshTokenByUserID(ctx, user.ID) - + if err != nil && err != ErrRefreshTokenNotFound { return LoginSuccess{}, err } @@ -48,7 +48,7 @@ func (s *Service) Login(ctx context.Context, email, phone string, password strin // If old refresh token is not revoked, revoke it if err == nil && !oldRefreshToken.Revoked { err = s.tokenStore.RevokeRefreshToken(ctx, oldRefreshToken.Token) - if(err != nil) { + if err != nil { return LoginSuccess{}, err } } diff --git a/internal/services/user/common.go b/internal/services/user/common.go index 9adf8e4..fd4f9aa 100644 --- a/internal/services/user/common.go +++ b/internal/services/user/common.go @@ -2,14 +2,32 @@ package user import ( "context" + "errors" + "fmt" "time" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" + "github.com/SamuelTariku/FortuneBet-Backend/internal/pkgs/helpers" + afro "github.com/amanuelabay/afrosms-go" + "github.com/resend/resend-go/v2" "golang.org/x/crypto/bcrypt" ) func (s *Service) SendOtp(ctx context.Context, sentTo string, otpFor domain.OtpFor, medium domain.OtpMedium) error { - otpCode := "123456" // Generate OTP code + otpCode := helpers.GenerateOTP() + + message := fmt.Sprintf("Welcome to Fortune bets, your OTP is %s please don't share with anyone.", otpCode) + + switch medium { + case domain.OtpMediumSms: + if err := s.SendSMSOTP(ctx, sentTo, message); err != nil { + return err + } + case domain.OtpMediumEmail: + if err := s.SendEmailOTP(ctx, sentTo, message); err != nil { + return err + } + } otp := domain.Otp{ SentTo: sentTo, @@ -21,19 +39,9 @@ func (s *Service) SendOtp(ctx context.Context, sentTo string, otpFor domain.OtpF ExpiresAt: time.Now().Add(OtpExpiry), } - err := s.otpStore.CreateOtp(ctx, otp) - if err != nil { - return err - } - - switch medium { - case domain.OtpMediumSms: - return s.smsGateway.SendSMSOTP(ctx, sentTo, otpCode) - case domain.OtpMediumEmail: - return s.emailGateway.SendEmailOTP(ctx, sentTo, otpCode) - } - return nil + return s.otpStore.CreateOtp(ctx, otp) } + func hashPassword(plaintextPassword string) ([]byte, error) { hash, err := bcrypt.GenerateFromPassword([]byte(plaintextPassword), 12) if err != nil { @@ -42,3 +50,50 @@ func hashPassword(plaintextPassword string) ([]byte, error) { return hash, nil } + +func (s *Service) SendSMSOTP(ctx context.Context, receiverPhone, message string) error { + apiKey := s.config.AFRO_SMS_API_KEY + senderName := s.config.AFRO_SMS_SENDER_NAME + hostURL := s.config.ADRO_SMS_HOST_URL + endpoint := "/api/send" + + // API endpoint has been updated + // TODO: no need for package for the afro message operations (pretty simple stuff) + request := afro.GetRequest(apiKey, endpoint, hostURL) + request.BaseURL = "https://api.afromessage.com/api/send" + + request.Method = "GET" + request.Sender(senderName) + request.To(receiverPhone, message) + + response, err := afro.MakeRequestWithContext(ctx, request) + if err != nil { + return err + } + + if response["acknowledge"] == "success" { + return nil + } else { + fmt.Println(response["response"].(map[string]interface{})) + return errors.New("SMS delivery failed") + } +} + +func (s *Service) SendEmailOTP(ctx context.Context, receiverEmail, message string) error { + apiKey := s.config.ResendApiKey + client := resend.NewClient(apiKey) + formattedSenderEmail := "FortuneBets <" + s.config.ResendSenderEmail + ">" + params := &resend.SendEmailRequest{ + From: formattedSenderEmail, + To: []string{receiverEmail}, + Subject: "FortuneBets - One Time Password", + Text: message, + } + + _, err := client.Emails.Send(params) + if err != nil { + return err + } + + return nil +} diff --git a/internal/services/user/service.go b/internal/services/user/service.go index cfa93fd..594a134 100644 --- a/internal/services/user/service.go +++ b/internal/services/user/service.go @@ -2,6 +2,8 @@ package user import ( "time" + + "github.com/SamuelTariku/FortuneBet-Backend/internal/config" ) const ( @@ -9,21 +11,19 @@ const ( ) type Service struct { - userStore UserStore - otpStore OtpStore - smsGateway SmsGateway - emailGateway EmailGateway + userStore UserStore + otpStore OtpStore + config *config.Config } func NewService( userStore UserStore, - otpStore OtpStore, smsGateway SmsGateway, - emailGateway EmailGateway, + otpStore OtpStore, + cfg *config.Config, ) *Service { return &Service{ - userStore: userStore, - otpStore: otpStore, - smsGateway: smsGateway, - emailGateway: emailGateway, + userStore: userStore, + otpStore: otpStore, + config: cfg, } } diff --git a/internal/web_server/handlers/auth_handler.go b/internal/web_server/handlers/auth_handler.go index abc26b7..1b3cc97 100644 --- a/internal/web_server/handlers/auth_handler.go +++ b/internal/web_server/handlers/auth_handler.go @@ -8,19 +8,21 @@ import ( "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/response" "github.com/gofiber/fiber/v2" ) + // loginCustomerReq represents the request body for the LoginCustomer endpoint. type loginCustomerReq struct { - Email string `json:"email" validate:"email" example:"john.doe@example.com"` - PhoneNumber string `json:"phone_number" validate:"required_without=Email" example:"1234567890"` - Password string `json:"password" validate:"required" example:"password123"` + Email string `json:"email" validate:"email" example:"john.doe@example.com"` + PhoneNumber string `json:"phone_number" validate:"required_without=Email" example:"1234567890"` + Password string `json:"password" validate:"required" example:"password123"` } // loginCustomerRes represents the response body for the LoginCustomer endpoint. type loginCustomerRes struct { - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - Role string `json:"role"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + Role string `json:"role"` } + // LoginCustomer godoc // @Summary Login customer // @Description Login customer @@ -74,8 +76,8 @@ func (h *Handler) LoginCustomer(c *fiber.Ctx) error { } type refreshToken struct { - AccessToken string `json:"access_token" validate:"required" example:""` - RefreshToken string `json:"refresh_token" validate:"required" example:""` + AccessToken string `json:"access_token" validate:"required" example:""` + RefreshToken string `json:"refresh_token" validate:"required" example:""` } // RefreshToken godoc diff --git a/internal/web_server/handlers/user.go b/internal/web_server/handlers/user.go index aabea39..522551c 100644 --- a/internal/web_server/handlers/user.go +++ b/internal/web_server/handlers/user.go @@ -2,6 +2,7 @@ package handlers import ( "errors" + "fmt" "strconv" "time" @@ -243,6 +244,7 @@ func (h *Handler) SendResetCode(c *fiber.Ctx) error { if err := h.userSvc.SendResetCode(c.Context(), medium, sentTo); err != nil { h.logger.Error("Failed to send reset code", "error", err) + fmt.Println(err) return fiber.NewError(fiber.StatusInternalServerError, "Failed to send reset code") } @@ -250,8 +252,8 @@ func (h *Handler) SendResetCode(c *fiber.Ctx) error { } type ResetPasswordReq struct { - Email string `json:"email" validate:"email" example:"john.doe@example.com"` - PhoneNumber string `json:"phone_number" validate:"required_without=Email" example:"1234567890"` + Email string `json:"email,omitempty" validate:"required_without=PhoneNumber,omitempty,email" example:"john.doe@example.com"` + PhoneNumber string `json:"phone_number,omitempty" validate:"required_without=Email,omitempty" example:"1234567890"` Password string `json:"password" validate:"required,min=8" example:"newpassword123"` Otp string `json:"otp" validate:"required" example:"123456"` }