diff --git a/config/configs.go b/config/configs.go index 6a6c529..7265f0a 100644 --- a/config/configs.go +++ b/config/configs.go @@ -29,6 +29,7 @@ type Config struct { Jwt Jwt `mapstructure:"jwt"` Log Log `mapstructure:"log"` S3Config S3Config `mapstructure:"s3"` + Fonnte Fonnte `mapstructure:"fonnte"` } var ( @@ -79,3 +80,15 @@ func (c *Config) Port() string { func (c *Config) LogFormat() string { return c.Log.LogFormat } + +func (c *Config) GetFonnte() *Fonnte { + return &c.Fonnte +} + +func (c *Config) GetCustomerJWTSecret() string { + return c.Jwt.Customer.Token.Secret +} + +func (c *Config) GetCustomerJWTExpiresTTL() int { + return c.Jwt.Customer.Token.ExpiresTTL +} diff --git a/config/fonnte.go b/config/fonnte.go new file mode 100644 index 0000000..1b84344 --- /dev/null +++ b/config/fonnte.go @@ -0,0 +1,19 @@ +package config + +type Fonnte struct { + ApiUrl string `mapstructure:"api_url"` + Token string `mapstructure:"token"` + Timeout int `mapstructure:"timeout"` +} + +func (f *Fonnte) GetApiUrl() string { + return f.ApiUrl +} + +func (f *Fonnte) GetToken() string { + return f.Token +} + +func (f *Fonnte) GetTimeout() int { + return f.Timeout +} diff --git a/config/jwt.go b/config/jwt.go index aec29b2..2f651bd 100644 --- a/config/jwt.go +++ b/config/jwt.go @@ -1,10 +1,15 @@ package config type Jwt struct { - Token Token `mapstructure:"token"` + Token Token `mapstructure:"token"` + Customer Customer `mapstructure:"customer"` } type Token struct { ExpiresTTL int `mapstructure:"expires-ttl"` Secret string `mapstructure:"secret"` } + +type Customer struct { + Token Token `mapstructure:"token"` +} diff --git a/infra/development.yaml b/infra/development.yaml index e4d1d63..45172ed 100644 --- a/infra/development.yaml +++ b/infra/development.yaml @@ -7,6 +7,9 @@ jwt: token: expires-ttl: 144000 secret: "5Lm25V3Qd7aut8dr4QUxm5PZUrSFs" + customer: + expires-ttl: 7776000 + secret: "z8d5TlFCT58Q$i0%S^2M&3WtE$PMgd" postgresql: host: 62.72.45.250 @@ -31,4 +34,9 @@ s3: log: log_format: 'json' - log_level: 'debug' \ No newline at end of file + log_level: 'debug' + +fonnte: + api_url: "https://api.fonnte.com/send" + token: "bADQrf9NTXfLZQCK2wGg" + timeout: 30 \ No newline at end of file diff --git a/internal/app/app.go b/internal/app/app.go index 0c7ad27..5722fa7 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -100,6 +100,8 @@ func (a *App) Initialize(cfg *config.Config) error { validators.rewardValidator, services.campaignService, validators.campaignValidator, + services.customerAuthService, + validators.customerAuthValidator, ) return nil @@ -182,6 +184,8 @@ type repositories struct { omsetTrackerRepo *repository.OmsetTrackerRepository rewardRepo repository.RewardRepository campaignRepo repository.CampaignRepository + customerAuthRepo repository.CustomerAuthRepository + otpRepo repository.OtpRepository txManager *repository.TxManager } @@ -224,6 +228,8 @@ func (a *App) initRepositories() *repositories { omsetTrackerRepo: repository.NewOmsetTrackerRepository(a.db), rewardRepo: repository.NewRewardRepository(a.db), campaignRepo: repository.NewCampaignRepository(a.db), + customerAuthRepo: repository.NewCustomerAuthRepository(a.db), + otpRepo: repository.NewOtpRepository(a.db), txManager: repository.NewTxManager(a.db), } } @@ -262,12 +268,16 @@ type processors struct { omsetTrackerProcessor *processor.OmsetTrackerProcessor rewardProcessor processor.RewardProcessor campaignProcessor processor.CampaignProcessor + customerAuthProcessor processor.CustomerAuthProcessor + otpProcessor processor.OtpProcessor fileClient processor.FileClient inventoryMovementService service.InventoryMovementService } func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processors { fileClient := client.NewFileClient(cfg.S3Config) + fonnteClient := client.NewFonnteClient(cfg.GetFonnte()) + otpProcessor := processor.NewOtpProcessor(fonnteClient, repos.otpRepo) inventoryMovementService := service.NewInventoryMovementService(repos.inventoryMovementRepo, repos.ingredientRepo) return &processors{ @@ -304,6 +314,8 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor omsetTrackerProcessor: processor.NewOmsetTrackerProcessor(repos.omsetTrackerRepo), rewardProcessor: processor.NewRewardProcessor(repos.rewardRepo), campaignProcessor: processor.NewCampaignProcessor(repos.campaignRepo), + customerAuthProcessor: processor.NewCustomerAuthProcessor(repos.customerAuthRepo, otpProcessor, repos.otpRepo, cfg.GetCustomerJWTSecret(), cfg.GetCustomerJWTExpiresTTL()), + otpProcessor: otpProcessor, fileClient: fileClient, inventoryMovementService: inventoryMovementService, } @@ -339,6 +351,7 @@ type services struct { gamificationService service.GamificationService rewardService service.RewardService campaignService service.CampaignService + customerAuthService service.CustomerAuthService } func (a *App) initServices(processors *processors, repos *repositories, cfg *config.Config) *services { @@ -372,6 +385,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con gamificationService := service.NewGamificationService(processors.customerPointsProcessor, processors.customerTokensProcessor, processors.tierProcessor, processors.gameProcessor, processors.gamePrizeProcessor, processors.gamePlayProcessor, processors.omsetTrackerProcessor) rewardService := service.NewRewardService(processors.rewardProcessor) campaignService := service.NewCampaignService(processors.campaignProcessor) + customerAuthService := service.NewCustomerAuthService(processors.customerAuthProcessor) // Update order service with order ingredient transaction service orderService = service.NewOrderServiceImpl(processors.orderProcessor, repos.tableRepo, orderIngredientTransactionService, processors.orderIngredientTransactionProcessor, *repos.productRecipeRepo, repos.txManager) @@ -406,6 +420,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con gamificationService: gamificationService, rewardService: rewardService, campaignService: campaignService, + customerAuthService: customerAuthService, } } @@ -442,6 +457,7 @@ type validators struct { gamificationValidator *validator.GamificationValidatorImpl rewardValidator validator.RewardValidator campaignValidator validator.CampaignValidator + customerAuthValidator validator.CustomerAuthValidator } func (a *App) initValidators() *validators { @@ -468,5 +484,6 @@ func (a *App) initValidators() *validators { gamificationValidator: validator.NewGamificationValidator(), rewardValidator: validator.NewRewardValidator(), campaignValidator: validator.NewCampaignValidator(), + customerAuthValidator: validator.NewCustomerAuthValidator(), } } diff --git a/internal/client/fonnte_client.go b/internal/client/fonnte_client.go new file mode 100644 index 0000000..f82a13b --- /dev/null +++ b/internal/client/fonnte_client.go @@ -0,0 +1,77 @@ +package client + +import ( + "bytes" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "apskel-pos-be/config" +) + +type FonnteClient interface { + SendWhatsAppMessage(target string, message string) error +} + +type fonnteClient struct { + httpClient *http.Client + apiUrl string + token string +} + +type FonnteResponse struct { + Status bool `json:"status"` + Message string `json:"message"` +} + +func NewFonnteClient(cfg *config.Fonnte) FonnteClient { + return &fonnteClient{ + httpClient: &http.Client{ + Timeout: time.Duration(cfg.GetTimeout()) * time.Second, + }, + apiUrl: cfg.GetApiUrl(), + token: cfg.GetToken(), + } +} + +func (c *fonnteClient) SendWhatsAppMessage(target string, message string) error { + // Prepare form data + data := url.Values{} + data.Set("target", target) + data.Set("message", message) + + // Create request + req, err := http.NewRequest("POST", c.apiUrl, bytes.NewBufferString(data.Encode())) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + // Set headers + req.Header.Set("Authorization", c.token) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + // Send request + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + // Read response body + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response body: %w", err) + } + + // Check HTTP status + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("fonnte API returned status %d: %s", resp.StatusCode, string(body)) + } + + // Log the response for debugging + fmt.Printf("Fonnte API response: %s\n", string(body)) + + return nil +} diff --git a/internal/constants/error.go b/internal/constants/error.go index 3cffc40..5209cef 100644 --- a/internal/constants/error.go +++ b/internal/constants/error.go @@ -53,6 +53,7 @@ const ( RewardEntity = "reward" CampaignEntity = "campaign" CampaignRuleEntity = "campaign_rule" + CustomerEntity = "customer" ) var HttpErrorMap = map[string]int{ diff --git a/internal/contract/customer_auth_contract.go b/internal/contract/customer_auth_contract.go new file mode 100644 index 0000000..47547d5 --- /dev/null +++ b/internal/contract/customer_auth_contract.go @@ -0,0 +1,144 @@ +package contract + +import ( + "time" + + "github.com/google/uuid" +) + +// Request Contracts +type CheckPhoneRequest struct { + PhoneNumber string `json:"phone_number" binding:"required"` + Password string `json:"password,omitempty"` // Optional - only required if user exists +} + +type RegisterStartRequest struct { + PhoneNumber string `json:"phone_number" binding:"required"` + Name string `json:"name" binding:"required"` + BirthDate string `json:"birth_date" binding:"required"` +} + +type RegisterVerifyOtpRequest struct { + RegistrationToken string `json:"registration_token" binding:"required"` + OtpCode string `json:"otp_code" binding:"required"` +} + +type RegisterSetPasswordRequest struct { + RegistrationToken string `json:"registration_token" binding:"required"` + Password string `json:"password" binding:"required,min=8"` + ConfirmPassword string `json:"confirm_password" binding:"required"` +} + +type CustomerLoginRequest struct { + PhoneNumber string `json:"phone_number" binding:"required"` + Password string `json:"password" binding:"required"` +} + +type ResendOtpRequest struct { + PhoneNumber string `json:"phone_number" binding:"required"` + Purpose string `json:"purpose" binding:"required,oneof=login registration"` +} + +// Response Contracts +type CheckPhoneResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data *CheckPhoneResponseData `json:"data,omitempty"` +} + +type CheckPhoneResponseData struct { + // For NOT_REGISTERED status + PhoneNumber string `json:"phone_number,omitempty"` + + // For PASSWORD_REQUIRED status + AccessToken string `json:"access_token,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` + User *CustomerUserData `json:"user,omitempty"` + + // For OTP_REQUIRED status (if password doesn't exist) + OtpToken string `json:"otp_token,omitempty"` + ExpiresIn int `json:"expires_in,omitempty"` +} + +type RegisterStartResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data *RegisterStartResponseData `json:"data,omitempty"` +} + +type RegisterStartResponseData struct { + RegistrationToken string `json:"registration_token"` + OtpToken string `json:"otp_token"` + ExpiresIn int `json:"expires_in"` +} + +type RegisterVerifyOtpResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data *RegisterVerifyOtpResponseData `json:"data,omitempty"` +} + +type RegisterVerifyOtpResponseData struct { + RegistrationToken string `json:"registration_token"` +} + +type RegisterSetPasswordResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data *RegisterSetPasswordResponseData `json:"data,omitempty"` +} + +type RegisterSetPasswordResponseData struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + User *CustomerUserData `json:"user"` +} + +type CustomerUserData struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + PhoneNumber string `json:"phone_number"` + BirthDate string `json:"birth_date"` +} + +type CustomerLoginResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data *CustomerLoginResponseData `json:"data,omitempty"` +} + +type CustomerLoginResponseData struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + User *CustomerUserData `json:"user"` +} + +type ResendOtpResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data *ResendOtpResponseData `json:"data,omitempty"` +} + +type ResendOtpResponseData struct { + OtpToken string `json:"otp_token"` + ExpiresIn int `json:"expires_in"` + NextResendIn int `json:"next_resend_in"` // Seconds until next resend is allowed +} + +// Internal structures for OTP and registration tokens +type OtpSession struct { + Token string `json:"token"` + Code string `json:"code"` + PhoneNumber string `json:"phone_number"` + ExpiresAt time.Time `json:"expires_at"` + Purpose string `json:"purpose"` // "login" or "registration" +} + +type RegistrationSession struct { + Token string `json:"token"` + PhoneNumber string `json:"phone_number"` + Name string `json:"name"` + BirthDate string `json:"birth_date"` + ExpiresAt time.Time `json:"expires_at"` + Step string `json:"step"` // "otp_sent", "otp_verified", "password_set" +} diff --git a/internal/entities/customer.go b/internal/entities/customer.go index e19451c..8ce719b 100644 --- a/internal/entities/customer.go +++ b/internal/entities/customer.go @@ -8,17 +8,20 @@ import ( ) type Customer struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` - OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id" validate:"required"` - Name string `gorm:"not null;size:255" json:"name" validate:"required"` - Email *string `gorm:"size:255;uniqueIndex" json:"email,omitempty"` - Phone *string `gorm:"size:20" json:"phone,omitempty"` - Address *string `gorm:"size:500" json:"address,omitempty"` - IsDefault bool `gorm:"default:false" json:"is_default"` - IsActive bool `gorm:"default:true" json:"is_active"` - Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` + OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id" validate:"required"` + Name string `gorm:"not null;size:255" json:"name" validate:"required"` + Email *string `gorm:"size:255;uniqueIndex" json:"email,omitempty"` + Phone *string `gorm:"size:20" json:"phone,omitempty"` + PhoneNumber *string `gorm:"size:20;uniqueIndex" json:"phone_number,omitempty"` + Address *string `gorm:"size:500" json:"address,omitempty"` + BirthDate *time.Time `gorm:"type:date" json:"birth_date,omitempty"` + PasswordHash *string `gorm:"size:255" json:"-"` + IsDefault bool `gorm:"default:false" json:"is_default"` + IsActive bool `gorm:"default:true" json:"is_active"` + Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` Organization Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"` Orders []Order `gorm:"foreignKey:CustomerID" json:"orders,omitempty"` diff --git a/internal/entities/entities.go b/internal/entities/entities.go index 8a50afc..1618997 100644 --- a/internal/entities/entities.go +++ b/internal/entities/entities.go @@ -34,6 +34,7 @@ func GetAllEntities() []interface{} { &Reward{}, &Campaign{}, &CampaignRule{}, + &OtpSession{}, // Analytics entities are not database tables, they are query results } } diff --git a/internal/entities/otp_session.go b/internal/entities/otp_session.go new file mode 100644 index 0000000..0b68f50 --- /dev/null +++ b/internal/entities/otp_session.go @@ -0,0 +1,53 @@ +package entities + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type OtpSession struct { + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` + Token string `gorm:"type:varchar(255);uniqueIndex;not null" json:"token"` + Code string `gorm:"type:varchar(10);not null" json:"code"` + PhoneNumber string `gorm:"type:varchar(20);not null;index" json:"phone_number"` + Purpose string `gorm:"type:varchar(50);not null;index" json:"purpose"` + ExpiresAt time.Time `gorm:"not null;index" json:"expires_at"` + IsUsed bool `gorm:"default:false;index" json:"is_used"` + AttemptsCount int `gorm:"default:0" json:"attempts_count"` + MaxAttempts int `gorm:"default:3" json:"max_attempts"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` +} + +func (o *OtpSession) BeforeCreate(tx *gorm.DB) error { + if o.ID == uuid.Nil { + o.ID = uuid.New() + } + return nil +} + +func (OtpSession) TableName() string { + return "otp_sessions" +} + +func (o *OtpSession) IsExpired() bool { + return time.Now().After(o.ExpiresAt) +} + +func (o *OtpSession) IsMaxAttemptsReached() bool { + return o.AttemptsCount >= o.MaxAttempts +} + +func (o *OtpSession) CanBeUsed() bool { + return !o.IsUsed && !o.IsExpired() && !o.IsMaxAttemptsReached() +} + +func (o *OtpSession) IncrementAttempts() { + o.AttemptsCount++ +} + +func (o *OtpSession) MarkAsUsed() { + o.IsUsed = true +} diff --git a/internal/handler/customer_auth_handler.go b/internal/handler/customer_auth_handler.go new file mode 100644 index 0000000..126d4bc --- /dev/null +++ b/internal/handler/customer_auth_handler.go @@ -0,0 +1,196 @@ +package handler + +import ( + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/contract" + "apskel-pos-be/internal/logger" + "apskel-pos-be/internal/service" + "apskel-pos-be/internal/util" + "apskel-pos-be/internal/validator" + + "github.com/gin-gonic/gin" +) + +type CustomerAuthHandler struct { + customerAuthService service.CustomerAuthService + customerAuthValidator validator.CustomerAuthValidator +} + +func NewCustomerAuthHandler(customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator) *CustomerAuthHandler { + return &CustomerAuthHandler{ + customerAuthService: customerAuthService, + customerAuthValidator: customerAuthValidator, + } +} + +func (h *CustomerAuthHandler) CheckPhone(c *gin.Context) { + ctx := c.Request.Context() + + var req contract.CheckPhoneRequest + if err := c.ShouldBindJSON(&req); err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("CustomerAuthHandler::CheckPhone -> request binding failed") + validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CustomerAuthHandler::CheckPhone") + return + } + + validationError, validationErrorCode := h.customerAuthValidator.ValidateCheckPhoneRequest(&req) + if validationError != nil { + logger.FromContext(c.Request.Context()).WithError(validationError).Error("CustomerAuthHandler::CheckPhone -> request validation failed") + validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CustomerAuthHandler::CheckPhone") + return + } + + response, err := h.customerAuthService.CheckPhoneNumber(ctx, &req) + if err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("CustomerAuthHandler::CheckPhone -> service call failed") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error())}), "CustomerAuthHandler::CheckPhone") + return + } + + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "CustomerAuthHandler::CheckPhone") +} + +func (h *CustomerAuthHandler) RegisterStart(c *gin.Context) { + ctx := c.Request.Context() + + var req contract.RegisterStartRequest + if err := c.ShouldBindJSON(&req); err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("CustomerAuthHandler::RegisterStart -> request binding failed") + validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CustomerAuthHandler::RegisterStart") + return + } + + validationError, validationErrorCode := h.customerAuthValidator.ValidateRegisterStartRequest(&req) + if validationError != nil { + logger.FromContext(c.Request.Context()).WithError(validationError).Error("CustomerAuthHandler::RegisterStart -> request validation failed") + validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CustomerAuthHandler::RegisterStart") + return + } + + response, err := h.customerAuthService.StartRegistration(ctx, &req) + if err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("CustomerAuthHandler::RegisterStart -> service call failed") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error())}), "CustomerAuthHandler::RegisterStart") + return + } + + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "CustomerAuthHandler::RegisterStart") +} + +func (h *CustomerAuthHandler) RegisterVerifyOtp(c *gin.Context) { + ctx := c.Request.Context() + + var req contract.RegisterVerifyOtpRequest + if err := c.ShouldBindJSON(&req); err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("CustomerAuthHandler::RegisterVerifyOtp -> request binding failed") + validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CustomerAuthHandler::RegisterVerifyOtp") + return + } + + validationError, validationErrorCode := h.customerAuthValidator.ValidateRegisterVerifyOtpRequest(&req) + if validationError != nil { + logger.FromContext(c.Request.Context()).WithError(validationError).Error("CustomerAuthHandler::RegisterVerifyOtp -> request validation failed") + validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CustomerAuthHandler::RegisterVerifyOtp") + return + } + + response, err := h.customerAuthService.VerifyOtp(ctx, &req) + if err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("CustomerAuthHandler::RegisterVerifyOtp -> service call failed") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error())}), "CustomerAuthHandler::RegisterVerifyOtp") + return + } + + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "CustomerAuthHandler::RegisterVerifyOtp") +} + +func (h *CustomerAuthHandler) RegisterSetPassword(c *gin.Context) { + ctx := c.Request.Context() + + var req contract.RegisterSetPasswordRequest + if err := c.ShouldBindJSON(&req); err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("CustomerAuthHandler::RegisterSetPassword -> request binding failed") + validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CustomerAuthHandler::RegisterSetPassword") + return + } + + validationError, validationErrorCode := h.customerAuthValidator.ValidateRegisterSetPasswordRequest(&req) + if validationError != nil { + logger.FromContext(c.Request.Context()).WithError(validationError).Error("CustomerAuthHandler::RegisterSetPassword -> request validation failed") + validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CustomerAuthHandler::RegisterSetPassword") + return + } + + response, err := h.customerAuthService.SetPassword(ctx, &req) + if err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("CustomerAuthHandler::RegisterSetPassword -> service call failed") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error())}), "CustomerAuthHandler::RegisterSetPassword") + return + } + + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "CustomerAuthHandler::RegisterSetPassword") +} + +func (h *CustomerAuthHandler) Login(c *gin.Context) { + ctx := c.Request.Context() + + var req contract.CustomerLoginRequest + if err := c.ShouldBindJSON(&req); err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("CustomerAuthHandler::Login -> request binding failed") + validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CustomerAuthHandler::Login") + return + } + + validationError, validationErrorCode := h.customerAuthValidator.ValidateCustomerLoginRequest(&req) + if validationError != nil { + logger.FromContext(c.Request.Context()).WithError(validationError).Error("CustomerAuthHandler::Login -> request validation failed") + validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CustomerAuthHandler::Login") + return + } + + response, err := h.customerAuthService.Login(ctx, &req) + if err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("CustomerAuthHandler::Login -> service call failed") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error())}), "CustomerAuthHandler::Login") + return + } + + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "CustomerAuthHandler::Login") +} + +func (h *CustomerAuthHandler) ResendOtp(c *gin.Context) { + ctx := c.Request.Context() + + var req contract.ResendOtpRequest + if err := c.ShouldBindJSON(&req); err != nil { + logger.FromContext(ctx).WithError(err).Error("CustomerAuthHandler::ResendOtp -> binding request failed") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())}), "CustomerAuthHandler::ResendOtp") + return + } + + // Validate request + if err, entity := h.customerAuthValidator.ValidateResendOtpRequest(&req); err != nil { + logger.FromContext(ctx).WithError(err).Error("CustomerAuthHandler::ResendOtp -> validation failed") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.MissingFieldErrorCode, entity, err.Error())}), "CustomerAuthHandler::ResendOtp") + return + } + + response, err := h.customerAuthService.ResendOtp(ctx, &req) + if err != nil { + logger.FromContext(ctx).WithError(err).Error("CustomerAuthHandler::ResendOtp -> service call failed") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error())}), "CustomerAuthHandler::ResendOtp") + return + } + + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "CustomerAuthHandler::ResendOtp") +} diff --git a/internal/models/customer_auth.go b/internal/models/customer_auth.go new file mode 100644 index 0000000..5405581 --- /dev/null +++ b/internal/models/customer_auth.go @@ -0,0 +1,144 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +// Request Models +type CheckPhoneRequest struct { + PhoneNumber string `json:"phone_number" binding:"required"` + Password string `json:"password,omitempty"` // Optional - only required if user exists +} + +type RegisterStartRequest struct { + PhoneNumber string `json:"phone_number" binding:"required"` + Name string `json:"name" binding:"required"` + BirthDate string `json:"birth_date" binding:"required"` +} + +type RegisterVerifyOtpRequest struct { + RegistrationToken string `json:"registration_token" binding:"required"` + OtpCode string `json:"otp_code" binding:"required"` +} + +type RegisterSetPasswordRequest struct { + RegistrationToken string `json:"registration_token" binding:"required"` + Password string `json:"password" binding:"required,min=8"` + ConfirmPassword string `json:"confirm_password" binding:"required"` +} + +type CustomerLoginRequest struct { + PhoneNumber string `json:"phone_number" binding:"required"` + Password string `json:"password" binding:"required"` +} + +type ResendOtpRequest struct { + PhoneNumber string `json:"phone_number" binding:"required"` + Purpose string `json:"purpose" binding:"required,oneof=login registration"` +} + +// Response Models +type CheckPhoneResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data *CheckPhoneResponseData `json:"data,omitempty"` +} + +type CheckPhoneResponseData struct { + // For NOT_REGISTERED status + PhoneNumber string `json:"phone_number,omitempty"` + + // For PASSWORD_REQUIRED status + AccessToken string `json:"access_token,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` + User *CustomerUserData `json:"user,omitempty"` + + // For OTP_REQUIRED status (if password doesn't exist) + OtpToken string `json:"otp_token,omitempty"` + ExpiresIn int `json:"expires_in,omitempty"` +} + +type RegisterStartResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data *RegisterStartResponseData `json:"data,omitempty"` +} + +type RegisterStartResponseData struct { + RegistrationToken string `json:"registration_token"` + OtpToken string `json:"otp_token"` + ExpiresIn int `json:"expires_in"` +} + +type RegisterVerifyOtpResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data *RegisterVerifyOtpResponseData `json:"data,omitempty"` +} + +type RegisterVerifyOtpResponseData struct { + RegistrationToken string `json:"registration_token"` +} + +type RegisterSetPasswordResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data *RegisterSetPasswordResponseData `json:"data,omitempty"` +} + +type RegisterSetPasswordResponseData struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + User *CustomerUserData `json:"user"` +} + +type CustomerUserData struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + PhoneNumber string `json:"phone_number"` + BirthDate string `json:"birth_date"` +} + +type CustomerLoginResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data *CustomerLoginResponseData `json:"data,omitempty"` +} + +type CustomerLoginResponseData struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + User *CustomerUserData `json:"user"` +} + +type ResendOtpResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data *ResendOtpResponseData `json:"data,omitempty"` +} + +type ResendOtpResponseData struct { + OtpToken string `json:"otp_token"` + ExpiresIn int `json:"expires_in"` + NextResendIn int `json:"next_resend_in"` // Seconds until next resend is allowed +} + +// Internal structures for OTP and registration tokens +type OtpSession struct { + Token string `json:"token"` + Code string `json:"code"` + PhoneNumber string `json:"phone_number"` + ExpiresAt time.Time `json:"expires_at"` + Purpose string `json:"purpose"` // "login" or "registration" +} + +type RegistrationSession struct { + Token string `json:"token"` + PhoneNumber string `json:"phone_number"` + Name string `json:"name"` + BirthDate string `json:"birth_date"` + ExpiresAt time.Time `json:"expires_at"` + Step string `json:"step"` // "otp_sent", "otp_verified", "password_set" +} diff --git a/internal/processor/customer_auth_processor.go b/internal/processor/customer_auth_processor.go new file mode 100644 index 0000000..402828a --- /dev/null +++ b/internal/processor/customer_auth_processor.go @@ -0,0 +1,436 @@ +package processor + +import ( + "context" + "fmt" + "time" + + "apskel-pos-be/internal/contract" + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/repository" + "apskel-pos-be/internal/util" + + "github.com/google/uuid" + "golang.org/x/crypto/bcrypt" +) + +type CustomerAuthProcessor interface { + CheckPhoneNumber(ctx context.Context, req *contract.CheckPhoneRequest) (*models.CheckPhoneResponse, error) + StartRegistration(ctx context.Context, req *contract.RegisterStartRequest) (*models.RegisterStartResponse, error) + VerifyOtp(ctx context.Context, req *contract.RegisterVerifyOtpRequest) (*models.RegisterVerifyOtpResponse, error) + SetPassword(ctx context.Context, req *contract.RegisterSetPasswordRequest) (*models.RegisterSetPasswordResponse, error) + Login(ctx context.Context, req *contract.CustomerLoginRequest) (*models.CustomerLoginResponse, error) + ResendOtp(ctx context.Context, req *contract.ResendOtpRequest) (*models.ResendOtpResponse, error) +} + +type customerAuthProcessor struct { + customerAuthRepo repository.CustomerAuthRepository + otpProcessor OtpProcessor + otpRepo repository.OtpRepository + jwtSecret string + tokenTTLMinutes int + otpStorage map[string]*models.OtpSession // In-memory storage for OTP sessions + registrationStorage map[string]*models.RegistrationSession // In-memory storage for registration sessions +} + +func NewCustomerAuthProcessor(customerAuthRepo repository.CustomerAuthRepository, otpProcessor OtpProcessor, otpRepo repository.OtpRepository, jwtSecret string, tokenTTLMinutes int) CustomerAuthProcessor { + return &customerAuthProcessor{ + customerAuthRepo: customerAuthRepo, + otpProcessor: otpProcessor, + otpRepo: otpRepo, + jwtSecret: jwtSecret, + tokenTTLMinutes: tokenTTLMinutes, + otpStorage: make(map[string]*models.OtpSession), + registrationStorage: make(map[string]*models.RegistrationSession), + } +} + +func (p *customerAuthProcessor) CheckPhoneNumber(ctx context.Context, req *contract.CheckPhoneRequest) (*models.CheckPhoneResponse, error) { + // Check if phone number exists in database + exists, err := p.customerAuthRepo.CheckPhoneNumberExists(ctx, req.PhoneNumber) + if err != nil { + return nil, fmt.Errorf("failed to check phone number: %w", err) + } + + if !exists { + // Phone number not registered + return &models.CheckPhoneResponse{ + Status: "NOT_REGISTERED", + Message: "Phone number not registered. Please continue registration.", + Data: &models.CheckPhoneResponseData{ + PhoneNumber: req.PhoneNumber, + }, + }, nil + } + + // Phone number exists, get customer details + customer, err := p.customerAuthRepo.GetCustomerByPhoneNumber(ctx, req.PhoneNumber) + if err != nil { + return nil, fmt.Errorf("failed to get customer: %w", err) + } + + if customer == nil { + return nil, fmt.Errorf("customer not found") + } + + // Check if customer has password set + if customer.PasswordHash == nil || *customer.PasswordHash == "" { + // Customer exists but no password set, send OTP for password setup + otpSession, err := p.otpProcessor.CreateOtpSession(ctx, req.PhoneNumber, "password_setup") + if err != nil { + return nil, fmt.Errorf("failed to create OTP session: %w", err) + } + + // Send OTP via WhatsApp + if err := p.otpProcessor.SendOtpViaWhatsApp(req.PhoneNumber, otpSession.Code, "password setup"); err != nil { + return nil, fmt.Errorf("failed to send OTP: %w", err) + } + + return &models.CheckPhoneResponse{ + Status: "OTP_REQUIRED", + Message: "OTP sent for password setup.", + Data: &models.CheckPhoneResponseData{ + OtpToken: otpSession.Token, + ExpiresIn: 300, + }, + }, nil + } + + // Customer exists and has password set, validate password if provided + if req.Password == "" { + return &models.CheckPhoneResponse{ + Status: "PASSWORD_REQUIRED", + Message: "Password is required for login.", + }, nil + } + + // Validate password + if err := bcrypt.CompareHashAndPassword([]byte(*customer.PasswordHash), []byte(req.Password)); err != nil { + return &models.CheckPhoneResponse{ + Status: "INVALID_PASSWORD", + Message: "Invalid password.", + }, nil + } + + // Generate JWT tokens using customer JWT util + accessToken, refreshToken, _, err := util.GenerateCustomerTokens(customer, p.jwtSecret, p.tokenTTLMinutes) + if err != nil { + return nil, fmt.Errorf("failed to generate tokens: %w", err) + } + + return &models.CheckPhoneResponse{ + Status: "SUCCESS", + Message: "Login successful.", + Data: &models.CheckPhoneResponseData{ + AccessToken: accessToken, + RefreshToken: refreshToken, + User: &models.CustomerUserData{ + ID: customer.ID, + Name: customer.Name, + PhoneNumber: *customer.PhoneNumber, + BirthDate: customer.BirthDate.Format("2006-01-02"), + }, + }, + }, nil +} + +func (p *customerAuthProcessor) StartRegistration(ctx context.Context, req *contract.RegisterStartRequest) (*models.RegisterStartResponse, error) { + // Check if phone number already exists + exists, err := p.customerAuthRepo.CheckPhoneNumberExists(ctx, req.PhoneNumber) + if err != nil { + return nil, fmt.Errorf("failed to check phone number: %w", err) + } + + if exists { + return nil, fmt.Errorf("phone number already registered") + } + + // Generate registration token and create OTP session + registrationToken := uuid.New().String() + + // Create OTP session for registration + otpSession, err := p.otpProcessor.CreateOtpSession(ctx, req.PhoneNumber, "registration") + if err != nil { + return nil, fmt.Errorf("failed to create OTP session: %w", err) + } + + // Store registration session + registrationSession := &models.RegistrationSession{ + Token: registrationToken, + PhoneNumber: req.PhoneNumber, + Name: req.Name, + BirthDate: req.BirthDate, + ExpiresAt: time.Now().Add(10 * time.Minute), + Step: "otp_sent", + } + + p.registrationStorage[registrationToken] = registrationSession + + // Send OTP via WhatsApp + if err := p.otpProcessor.SendOtpViaWhatsApp(req.PhoneNumber, otpSession.Code, "registration"); err != nil { + return nil, fmt.Errorf("failed to send OTP: %w", err) + } + + return &models.RegisterStartResponse{ + Status: "PENDING_OTP", + Message: "OTP sent to phone number for verification.", + Data: &models.RegisterStartResponseData{ + RegistrationToken: registrationToken, + OtpToken: otpSession.Token, + ExpiresIn: 300, + }, + }, nil +} + +func (p *customerAuthProcessor) VerifyOtp(ctx context.Context, req *contract.RegisterVerifyOtpRequest) (*models.RegisterVerifyOtpResponse, error) { + // Get registration session + registrationSession, exists := p.registrationStorage[req.RegistrationToken] + if !exists { + return nil, fmt.Errorf("invalid or expired registration token") + } + + if time.Now().After(registrationSession.ExpiresAt) { + delete(p.registrationStorage, req.RegistrationToken) + return nil, fmt.Errorf("registration token expired") + } + + // Validate OTP format + if !p.otpProcessor.ValidateOtpCode(req.OtpCode) { + return &models.RegisterVerifyOtpResponse{ + Status: "FAILED", + Message: "Invalid OTP format.", + }, nil + } + + // Get the OTP session for this phone number and purpose + otpSession, err := p.otpRepo.GetOtpSessionByPhoneAndPurpose(ctx, registrationSession.PhoneNumber, "registration") + if err != nil { + return &models.RegisterVerifyOtpResponse{ + Status: "FAILED", + Message: "Failed to validate OTP session.", + }, nil + } + + if otpSession == nil { + return &models.RegisterVerifyOtpResponse{ + Status: "FAILED", + Message: "No active OTP session found.", + }, nil + } + + // Verify OTP code and mark as used + if otpSession.Code != req.OtpCode { + otpSession.IncrementAttempts() + p.otpRepo.UpdateOtpSession(ctx, otpSession) + return &models.RegisterVerifyOtpResponse{ + Status: "FAILED", + Message: "Invalid OTP code.", + }, nil + } + + // Mark OTP as used + otpSession.MarkAsUsed() + if err := p.otpRepo.UpdateOtpSession(ctx, otpSession); err != nil { + return &models.RegisterVerifyOtpResponse{ + Status: "FAILED", + Message: "Failed to update OTP session.", + }, nil + } + + // Update registration session + registrationSession.Step = "otp_verified" + p.registrationStorage[req.RegistrationToken] = registrationSession + + return &models.RegisterVerifyOtpResponse{ + Status: "OTP_VERIFIED", + Message: "OTP verified, continue to set password.", + Data: &models.RegisterVerifyOtpResponseData{ + RegistrationToken: req.RegistrationToken, + }, + }, nil +} + +func (p *customerAuthProcessor) SetPassword(ctx context.Context, req *contract.RegisterSetPasswordRequest) (*models.RegisterSetPasswordResponse, error) { + // Validate passwords match + if req.Password != req.ConfirmPassword { + return nil, fmt.Errorf("passwords do not match") + } + + // Get registration session + registrationSession, exists := p.registrationStorage[req.RegistrationToken] + if !exists { + return nil, fmt.Errorf("invalid or expired registration token") + } + + if time.Now().After(registrationSession.ExpiresAt) { + delete(p.registrationStorage, req.RegistrationToken) + return nil, fmt.Errorf("registration token expired") + } + + if registrationSession.Step != "otp_verified" { + return nil, fmt.Errorf("OTP verification required before setting password") + } + + // Hash password + passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + return nil, fmt.Errorf("failed to hash password: %w", err) + } + + passwordHashStr := string(passwordHash) + + // Parse birth date + birthDate, err := time.Parse("2006-01-02", registrationSession.BirthDate) + if err != nil { + return nil, fmt.Errorf("invalid birth date format: %w", err) + } + + // Create customer + customer := &entities.Customer{ + Name: registrationSession.Name, + PhoneNumber: ®istrationSession.PhoneNumber, + BirthDate: &birthDate, + PasswordHash: &passwordHashStr, + IsActive: true, + // Note: OrganizationID should be set based on your business logic + // For now, we'll use a default organization or require it in the request + } + + if err := p.customerAuthRepo.CreateCustomer(ctx, customer); err != nil { + return nil, fmt.Errorf("failed to create customer: %w", err) + } + + // Clean up registration session + delete(p.registrationStorage, req.RegistrationToken) + + // Generate JWT tokens using customer JWT util + accessToken, refreshToken, _, err := util.GenerateCustomerTokens(customer, p.jwtSecret, p.tokenTTLMinutes) + if err != nil { + return nil, fmt.Errorf("failed to generate tokens: %w", err) + } + + return &models.RegisterSetPasswordResponse{ + Status: "REGISTERED", + Message: "Registration completed successfully.", + Data: &models.RegisterSetPasswordResponseData{ + AccessToken: accessToken, + RefreshToken: refreshToken, + User: &models.CustomerUserData{ + ID: customer.ID, + Name: customer.Name, + PhoneNumber: *customer.PhoneNumber, + BirthDate: registrationSession.BirthDate, + }, + }, + }, nil +} + +func (p *customerAuthProcessor) Login(ctx context.Context, req *contract.CustomerLoginRequest) (*models.CustomerLoginResponse, error) { + // Get customer by phone number + customer, err := p.customerAuthRepo.GetCustomerByPhoneNumber(ctx, req.PhoneNumber) + if err != nil { + return nil, fmt.Errorf("failed to get customer: %w", err) + } + + if customer == nil { + return nil, fmt.Errorf("customer not found") + } + + if customer.PasswordHash == nil { + return nil, fmt.Errorf("customer not properly registered") + } + + // Verify password + if err := bcrypt.CompareHashAndPassword([]byte(*customer.PasswordHash), []byte(req.Password)); err != nil { + return nil, fmt.Errorf("invalid password") + } + + // Generate JWT tokens using customer JWT util + accessToken, refreshToken, _, err := util.GenerateCustomerTokens(customer, p.jwtSecret, p.tokenTTLMinutes) + if err != nil { + return nil, fmt.Errorf("failed to generate tokens: %w", err) + } + + return &models.CustomerLoginResponse{ + Status: "SUCCESS", + Message: "Login successful.", + Data: &models.CustomerLoginResponseData{ + AccessToken: accessToken, + RefreshToken: refreshToken, + User: &models.CustomerUserData{ + ID: customer.ID, + Name: customer.Name, + PhoneNumber: *customer.PhoneNumber, + BirthDate: customer.BirthDate.Format("2006-01-02"), + }, + }, + }, nil +} + +func (p *customerAuthProcessor) ResendOtp(ctx context.Context, req *contract.ResendOtpRequest) (*models.ResendOtpResponse, error) { + // Check if resend is allowed + canResend, secondsUntilNext, err := p.otpProcessor.CanResendOtp(ctx, req.PhoneNumber, req.Purpose) + if err != nil { + return nil, fmt.Errorf("failed to check resend eligibility: %w", err) + } + + if !canResend { + return &models.ResendOtpResponse{ + Status: "RESEND_NOT_ALLOWED", + Message: fmt.Sprintf("Please wait %d seconds before requesting a new OTP", secondsUntilNext), + Data: &models.ResendOtpResponseData{ + NextResendIn: secondsUntilNext, + }, + }, nil + } + + // For registration purpose, check if phone number is already registered + if req.Purpose == "registration" { + exists, err := p.customerAuthRepo.CheckPhoneNumberExists(ctx, req.PhoneNumber) + if err != nil { + return nil, fmt.Errorf("failed to check phone number: %w", err) + } + if exists { + return &models.ResendOtpResponse{ + Status: "PHONE_ALREADY_REGISTERED", + Message: "Phone number is already registered. Please use login instead.", + }, nil + } + } + + // For login purpose, check if phone number is registered + if req.Purpose == "login" { + exists, err := p.customerAuthRepo.CheckPhoneNumberExists(ctx, req.PhoneNumber) + if err != nil { + return nil, fmt.Errorf("failed to check phone number: %w", err) + } + if !exists { + return &models.ResendOtpResponse{ + Status: "PHONE_NOT_REGISTERED", + Message: "Phone number is not registered. Please register first.", + }, nil + } + } + + // Resend OTP + otpSession, err := p.otpProcessor.ResendOtpSession(ctx, req.PhoneNumber, req.Purpose) + if err != nil { + return nil, fmt.Errorf("failed to resend OTP: %w", err) + } + + // Calculate next resend time (60 seconds from now) + nextResendIn := 60 + + return &models.ResendOtpResponse{ + Status: "SUCCESS", + Message: "OTP resent successfully.", + Data: &models.ResendOtpResponseData{ + OtpToken: otpSession.Token, + ExpiresIn: 300, // 5 minutes + NextResendIn: nextResendIn, + }, + }, nil +} + +// Helper functions - OTP generation is now handled by OtpProcessor diff --git a/internal/processor/otp_processor.go b/internal/processor/otp_processor.go new file mode 100644 index 0000000..f0616ed --- /dev/null +++ b/internal/processor/otp_processor.go @@ -0,0 +1,238 @@ +package processor + +import ( + "context" + "fmt" + "math/rand" + "time" + + "apskel-pos-be/internal/client" + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/repository" + + "github.com/google/uuid" +) + +type OtpProcessor interface { + GenerateOtpCode() string + CreateOtpSession(ctx context.Context, phoneNumber string, purpose string) (*entities.OtpSession, error) + ResendOtpSession(ctx context.Context, phoneNumber string, purpose string) (*entities.OtpSession, error) + SendOtpViaWhatsApp(phoneNumber string, otpCode string, purpose string) error + ValidateOtpCode(code string) bool + ValidateOtpSession(ctx context.Context, token string, code string) (*entities.OtpSession, error) + InvalidateOtpSession(ctx context.Context, token string) error + CleanupExpiredOtps(ctx context.Context) error + CanResendOtp(ctx context.Context, phoneNumber string, purpose string) (bool, int, error) // Returns (canResend, secondsUntilNext, error) +} + +type otpProcessor struct { + fonnteClient client.FonnteClient + otpRepo repository.OtpRepository +} + +func NewOtpProcessor(fonnteClient client.FonnteClient, otpRepo repository.OtpRepository) OtpProcessor { + return &otpProcessor{ + fonnteClient: fonnteClient, + otpRepo: otpRepo, + } +} + +func (p *otpProcessor) GenerateOtpCode() string { + // Generate a 6-digit OTP code + rand.Seed(time.Now().UnixNano()) + code := rand.Intn(900000) + 100000 // Generates number between 100000-999999 + return fmt.Sprintf("%06d", code) +} + +func (p *otpProcessor) CreateOtpSession(ctx context.Context, phoneNumber string, purpose string) (*entities.OtpSession, error) { + // Generate OTP code and token + otpCode := p.GenerateOtpCode() + token := uuid.New().String() + + // Invalidate any existing OTP sessions for this phone number and purpose + if err := p.otpRepo.InvalidateOtpSessionsByPhone(ctx, phoneNumber, purpose); err != nil { + return nil, fmt.Errorf("failed to invalidate existing OTP sessions: %w", err) + } + + // Create new OTP session + otpSession := &entities.OtpSession{ + Token: token, + Code: otpCode, + PhoneNumber: phoneNumber, + Purpose: purpose, + ExpiresAt: time.Now().Add(5 * time.Minute), + IsUsed: false, + AttemptsCount: 0, + MaxAttempts: 3, + } + + if err := p.otpRepo.CreateOtpSession(ctx, otpSession); err != nil { + return nil, fmt.Errorf("failed to create OTP session: %w", err) + } + + return otpSession, nil +} + +func (p *otpProcessor) ResendOtpSession(ctx context.Context, phoneNumber string, purpose string) (*entities.OtpSession, error) { + // Check if resend is allowed + canResend, secondsUntilNext, err := p.CanResendOtp(ctx, phoneNumber, purpose) + if err != nil { + return nil, fmt.Errorf("failed to check resend eligibility: %w", err) + } + + if !canResend { + return nil, fmt.Errorf("resend not allowed yet, try again in %d seconds", secondsUntilNext) + } + + // Create new OTP session (this will invalidate existing ones) + otpSession, err := p.CreateOtpSession(ctx, phoneNumber, purpose) + if err != nil { + return nil, fmt.Errorf("failed to create resend OTP session: %w", err) + } + + // Send OTP via WhatsApp + if err := p.SendOtpViaWhatsApp(phoneNumber, otpSession.Code, purpose); err != nil { + return nil, fmt.Errorf("failed to send resend OTP: %w", err) + } + + return otpSession, nil +} + +func (p *otpProcessor) CanResendOtp(ctx context.Context, phoneNumber string, purpose string) (bool, int, error) { + // Get the last OTP session for this phone number and purpose + lastOtpSession, err := p.otpRepo.GetLastOtpSessionByPhoneAndPurpose(ctx, phoneNumber, purpose) + if err != nil { + return false, 0, fmt.Errorf("failed to get last OTP session: %w", err) + } + + // If no previous OTP session exists, resend is allowed immediately + if lastOtpSession == nil { + return true, 0, nil + } + + // Calculate time since last OTP was created + timeSinceLastOtp := time.Since(lastOtpSession.CreatedAt) + + // Minimum time between OTP sends (60 seconds) + minResendInterval := 60 * time.Second + + if timeSinceLastOtp < minResendInterval { + secondsUntilNext := int((minResendInterval - timeSinceLastOtp).Seconds()) + return false, secondsUntilNext, nil + } + + return true, 0, nil +} + +func (p *otpProcessor) SendOtpViaWhatsApp(phoneNumber string, otpCode string, purpose string) error { + // Format phone number (remove any non-digit characters and ensure it starts with country code) + formattedPhone := p.formatPhoneNumber(phoneNumber) + + // Create message based on purpose + var message string + switch purpose { + case "login": + message = fmt.Sprintf("Kode OTP untuk login kamu adalah %s. Berlaku 5 menit.", otpCode) + case "registration": + message = fmt.Sprintf("Kode OTP untuk registrasi kamu adalah %s. Berlaku 5 menit.", otpCode) + default: + message = fmt.Sprintf("Kode OTP kamu adalah %s. Berlaku 5 menit.", otpCode) + } + + // Send message via Fonnte + if err := p.fonnteClient.SendWhatsAppMessage(formattedPhone, message); err != nil { + fmt.Printf("Failed to send OTP via WhatsApp to %s: %v\n", formattedPhone, err) + return fmt.Errorf("failed to send OTP via WhatsApp: %w", err) + } + + fmt.Printf("OTP sent successfully to %s for purpose: %s\n", formattedPhone, purpose) + return nil +} + +func (p *otpProcessor) ValidateOtpCode(code string) bool { + // Basic validation: should be 6 digits + if len(code) != 6 { + return false + } + + // Check if all characters are digits + for _, char := range code { + if char < '0' || char > '9' { + return false + } + } + + return true +} + +func (p *otpProcessor) ValidateOtpSession(ctx context.Context, token string, code string) (*entities.OtpSession, error) { + // Get OTP session by token + otpSession, err := p.otpRepo.GetOtpSessionByToken(ctx, token) + if err != nil { + return nil, fmt.Errorf("failed to get OTP session: %w", err) + } + + if otpSession == nil { + return nil, fmt.Errorf("invalid OTP token") + } + + // Check if OTP can be used + if !otpSession.CanBeUsed() { + // Update attempts count if max attempts not reached + if !otpSession.IsMaxAttemptsReached() { + otpSession.IncrementAttempts() + p.otpRepo.UpdateOtpSession(ctx, otpSession) + } + return nil, fmt.Errorf("OTP session expired, used, or max attempts reached") + } + + // Validate OTP code + if otpSession.Code != code { + otpSession.IncrementAttempts() + if err := p.otpRepo.UpdateOtpSession(ctx, otpSession); err != nil { + return nil, fmt.Errorf("failed to update OTP session attempts: %w", err) + } + return nil, fmt.Errorf("invalid OTP code") + } + + // Mark as used + otpSession.MarkAsUsed() + if err := p.otpRepo.UpdateOtpSession(ctx, otpSession); err != nil { + return nil, fmt.Errorf("failed to mark OTP as used: %w", err) + } + + return otpSession, nil +} + +func (p *otpProcessor) InvalidateOtpSession(ctx context.Context, token string) error { + return p.otpRepo.DeleteOtpSession(ctx, token) +} + +func (p *otpProcessor) CleanupExpiredOtps(ctx context.Context) error { + return p.otpRepo.DeleteExpiredOtpSessions(ctx) +} + +func (p *otpProcessor) formatPhoneNumber(phoneNumber string) string { + // Remove all non-digit characters + digits := "" + for _, char := range phoneNumber { + if char >= '0' && char <= '9' { + digits += string(char) + } + } + + // If it doesn't start with country code, assume it's Indonesian (+62) + if len(digits) == 0 { + return phoneNumber // Return original if empty + } + + // If starts with 0, replace with 62 + if len(digits) > 0 && digits[0] == '0' { + digits = "62" + digits[1:] + } else if len(digits) > 0 && digits[:2] != "62" { + // If doesn't start with 62, add it + digits = "62" + digits + } + + return digits +} diff --git a/internal/repository/customer_auth_repository.go b/internal/repository/customer_auth_repository.go new file mode 100644 index 0000000..c2132cd --- /dev/null +++ b/internal/repository/customer_auth_repository.go @@ -0,0 +1,80 @@ +package repository + +import ( + "context" + "fmt" + + "apskel-pos-be/internal/entities" + + "gorm.io/gorm" +) + +type CustomerAuthRepository interface { + GetCustomerByPhoneNumber(ctx context.Context, phoneNumber string) (*entities.Customer, error) + GetCustomerByID(ctx context.Context, id string) (*entities.Customer, error) + CreateCustomer(ctx context.Context, customer *entities.Customer) error + UpdateCustomer(ctx context.Context, customer *entities.Customer) error + CheckPhoneNumberExists(ctx context.Context, phoneNumber string) (bool, error) + SetCustomerPassword(ctx context.Context, customerID string, passwordHash string) error +} + +type customerAuthRepository struct { + db *gorm.DB +} + +func NewCustomerAuthRepository(db *gorm.DB) CustomerAuthRepository { + return &customerAuthRepository{ + db: db, + } +} + +func (r *customerAuthRepository) GetCustomerByPhoneNumber(ctx context.Context, phoneNumber string) (*entities.Customer, error) { + var customer entities.Customer + if err := r.db.WithContext(ctx).Where("phone_number = ?", phoneNumber).First(&customer).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil // Customer not found, not an error + } + return nil, fmt.Errorf("failed to get customer by phone number: %w", err) + } + return &customer, nil +} + +func (r *customerAuthRepository) GetCustomerByID(ctx context.Context, id string) (*entities.Customer, error) { + var customer entities.Customer + if err := r.db.WithContext(ctx).Where("id = ?", id).First(&customer).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, fmt.Errorf("customer not found") + } + return nil, fmt.Errorf("failed to get customer by ID: %w", err) + } + return &customer, nil +} + +func (r *customerAuthRepository) CreateCustomer(ctx context.Context, customer *entities.Customer) error { + if err := r.db.WithContext(ctx).Create(customer).Error; err != nil { + return fmt.Errorf("failed to create customer: %w", err) + } + return nil +} + +func (r *customerAuthRepository) UpdateCustomer(ctx context.Context, customer *entities.Customer) error { + if err := r.db.WithContext(ctx).Save(customer).Error; err != nil { + return fmt.Errorf("failed to update customer: %w", err) + } + return nil +} + +func (r *customerAuthRepository) CheckPhoneNumberExists(ctx context.Context, phoneNumber string) (bool, error) { + var count int64 + if err := r.db.WithContext(ctx).Model(&entities.Customer{}).Where("phone_number = ?", phoneNumber).Count(&count).Error; err != nil { + return false, fmt.Errorf("failed to check phone number existence: %w", err) + } + return count > 0, nil +} + +func (r *customerAuthRepository) SetCustomerPassword(ctx context.Context, customerID string, passwordHash string) error { + if err := r.db.WithContext(ctx).Model(&entities.Customer{}).Where("id = ?", customerID).Update("password_hash", passwordHash).Error; err != nil { + return fmt.Errorf("failed to set customer password: %w", err) + } + return nil +} diff --git a/internal/repository/otp_repository.go b/internal/repository/otp_repository.go new file mode 100644 index 0000000..f8f7b12 --- /dev/null +++ b/internal/repository/otp_repository.go @@ -0,0 +1,103 @@ +package repository + +import ( + "context" + "fmt" + "time" + + "apskel-pos-be/internal/entities" + + "gorm.io/gorm" +) + +type OtpRepository interface { + CreateOtpSession(ctx context.Context, otpSession *entities.OtpSession) error + GetOtpSessionByToken(ctx context.Context, token string) (*entities.OtpSession, error) + GetOtpSessionByPhoneAndPurpose(ctx context.Context, phoneNumber string, purpose string) (*entities.OtpSession, error) + GetLastOtpSessionByPhoneAndPurpose(ctx context.Context, phoneNumber string, purpose string) (*entities.OtpSession, error) + UpdateOtpSession(ctx context.Context, otpSession *entities.OtpSession) error + DeleteOtpSession(ctx context.Context, token string) error + DeleteExpiredOtpSessions(ctx context.Context) error + InvalidateOtpSessionsByPhone(ctx context.Context, phoneNumber string, purpose string) error +} + +type otpRepository struct { + db *gorm.DB +} + +func NewOtpRepository(db *gorm.DB) OtpRepository { + return &otpRepository{ + db: db, + } +} + +func (r *otpRepository) CreateOtpSession(ctx context.Context, otpSession *entities.OtpSession) error { + if err := r.db.WithContext(ctx).Create(otpSession).Error; err != nil { + return fmt.Errorf("failed to create OTP session: %w", err) + } + return nil +} + +func (r *otpRepository) GetOtpSessionByToken(ctx context.Context, token string) (*entities.OtpSession, error) { + var otpSession entities.OtpSession + if err := r.db.WithContext(ctx).Where("token = ?", token).First(&otpSession).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil // OTP session not found, not an error + } + return nil, fmt.Errorf("failed to get OTP session by token: %w", err) + } + return &otpSession, nil +} + +func (r *otpRepository) GetOtpSessionByPhoneAndPurpose(ctx context.Context, phoneNumber string, purpose string) (*entities.OtpSession, error) { + var otpSession entities.OtpSession + if err := r.db.WithContext(ctx).Where("phone_number = ? AND purpose = ? AND is_used = false AND expires_at > ?", + phoneNumber, purpose, time.Now()).Order("created_at DESC").First(&otpSession).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil // No active OTP session found + } + return nil, fmt.Errorf("failed to get OTP session by phone and purpose: %w", err) + } + return &otpSession, nil +} + +func (r *otpRepository) GetLastOtpSessionByPhoneAndPurpose(ctx context.Context, phoneNumber string, purpose string) (*entities.OtpSession, error) { + var otpSession entities.OtpSession + if err := r.db.WithContext(ctx).Where("phone_number = ? AND purpose = ?", + phoneNumber, purpose).Order("created_at DESC").First(&otpSession).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil // No OTP session found + } + return nil, fmt.Errorf("failed to get last OTP session by phone and purpose: %w", err) + } + return &otpSession, nil +} + +func (r *otpRepository) UpdateOtpSession(ctx context.Context, otpSession *entities.OtpSession) error { + if err := r.db.WithContext(ctx).Save(otpSession).Error; err != nil { + return fmt.Errorf("failed to update OTP session: %w", err) + } + return nil +} + +func (r *otpRepository) DeleteOtpSession(ctx context.Context, token string) error { + if err := r.db.WithContext(ctx).Where("token = ?", token).Delete(&entities.OtpSession{}).Error; err != nil { + return fmt.Errorf("failed to delete OTP session: %w", err) + } + return nil +} + +func (r *otpRepository) DeleteExpiredOtpSessions(ctx context.Context) error { + if err := r.db.WithContext(ctx).Where("expires_at < ?", time.Now()).Delete(&entities.OtpSession{}).Error; err != nil { + return fmt.Errorf("failed to delete expired OTP sessions: %w", err) + } + return nil +} + +func (r *otpRepository) InvalidateOtpSessionsByPhone(ctx context.Context, phoneNumber string, purpose string) error { + if err := r.db.WithContext(ctx).Model(&entities.OtpSession{}).Where("phone_number = ? AND purpose = ? AND is_used = false", + phoneNumber, purpose).Update("is_used", true).Error; err != nil { + return fmt.Errorf("failed to invalidate OTP sessions: %w", err) + } + return nil +} diff --git a/internal/router/router.go b/internal/router/router.go index e30a5b9..918caa6 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -43,6 +43,7 @@ type Router struct { gamificationHandler *handler.GamificationHandler rewardHandler *handler.RewardHandler campaignHandler *handler.CampaignHandler + customerAuthHandler *handler.CustomerAuthHandler authMiddleware *middleware.AuthMiddleware } @@ -99,7 +100,9 @@ func NewRouter(cfg *config.Config, rewardService service.RewardService, rewardValidator validator.RewardValidator, campaignService service.CampaignService, - campaignValidator validator.CampaignValidator) *Router { + campaignValidator validator.CampaignValidator, + customerAuthService service.CustomerAuthService, + customerAuthValidator validator.CustomerAuthValidator) *Router { return &Router{ config: cfg, @@ -132,6 +135,7 @@ func NewRouter(cfg *config.Config, gamificationHandler: handler.NewGamificationHandler(gamificationService, gamificationValidator), rewardHandler: handler.NewRewardHandler(rewardService, rewardValidator), campaignHandler: handler.NewCampaignHandler(campaignService, campaignValidator), + customerAuthHandler: handler.NewCustomerAuthHandler(customerAuthService, customerAuthValidator), authMiddleware: authMiddleware, } } @@ -166,6 +170,17 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { auth.GET("/profile", r.authHandler.GetProfile) } + // Customer authentication routes + customerAuth := v1.Group("/customer-auth") + { + customerAuth.POST("/check-phone", r.customerAuthHandler.CheckPhone) + customerAuth.POST("/register/start", r.customerAuthHandler.RegisterStart) + customerAuth.POST("/register/verify-otp", r.customerAuthHandler.RegisterVerifyOtp) + customerAuth.POST("/register/set-password", r.customerAuthHandler.RegisterSetPassword) + customerAuth.POST("/login", r.customerAuthHandler.Login) + customerAuth.POST("/resend-otp", r.customerAuthHandler.ResendOtp) + } + organizations := v1.Group("/organizations") { organizations.POST("", r.organizationHandler.CreateOrganization) diff --git a/internal/service/customer_auth_service.go b/internal/service/customer_auth_service.go new file mode 100644 index 0000000..93a976e --- /dev/null +++ b/internal/service/customer_auth_service.go @@ -0,0 +1,126 @@ +package service + +import ( + "context" + "fmt" + + "apskel-pos-be/internal/contract" + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/processor" +) + +type CustomerAuthService interface { + CheckPhoneNumber(ctx context.Context, req *contract.CheckPhoneRequest) (*models.CheckPhoneResponse, error) + StartRegistration(ctx context.Context, req *contract.RegisterStartRequest) (*models.RegisterStartResponse, error) + VerifyOtp(ctx context.Context, req *contract.RegisterVerifyOtpRequest) (*models.RegisterVerifyOtpResponse, error) + SetPassword(ctx context.Context, req *contract.RegisterSetPasswordRequest) (*models.RegisterSetPasswordResponse, error) + Login(ctx context.Context, req *contract.CustomerLoginRequest) (*models.CustomerLoginResponse, error) + ResendOtp(ctx context.Context, req *contract.ResendOtpRequest) (*models.ResendOtpResponse, error) +} + +type customerAuthService struct { + customerAuthProcessor processor.CustomerAuthProcessor +} + +func NewCustomerAuthService(customerAuthProcessor processor.CustomerAuthProcessor) CustomerAuthService { + return &customerAuthService{ + customerAuthProcessor: customerAuthProcessor, + } +} + +func (s *customerAuthService) CheckPhoneNumber(ctx context.Context, req *contract.CheckPhoneRequest) (*models.CheckPhoneResponse, error) { + // Validate request + if req.PhoneNumber == "" { + return nil, fmt.Errorf("phone number is required") + } + + response, err := s.customerAuthProcessor.CheckPhoneNumber(ctx, req) + if err != nil { + return nil, fmt.Errorf("failed to check phone number: %w", err) + } + + return response, nil +} + +func (s *customerAuthService) StartRegistration(ctx context.Context, req *contract.RegisterStartRequest) (*models.RegisterStartResponse, error) { + // Validate request + if req.PhoneNumber == "" { + return nil, fmt.Errorf("phone number is required") + } + if req.Name == "" { + return nil, fmt.Errorf("name is required") + } + if req.BirthDate == "" { + return nil, fmt.Errorf("birth date is required") + } + + response, err := s.customerAuthProcessor.StartRegistration(ctx, req) + if err != nil { + return nil, fmt.Errorf("failed to start registration: %w", err) + } + + return response, nil +} + +func (s *customerAuthService) VerifyOtp(ctx context.Context, req *contract.RegisterVerifyOtpRequest) (*models.RegisterVerifyOtpResponse, error) { + // Validate request + if req.RegistrationToken == "" { + return nil, fmt.Errorf("registration token is required") + } + if req.OtpCode == "" { + return nil, fmt.Errorf("OTP code is required") + } + + response, err := s.customerAuthProcessor.VerifyOtp(ctx, req) + if err != nil { + return nil, fmt.Errorf("failed to verify OTP: %w", err) + } + + return response, nil +} + +func (s *customerAuthService) SetPassword(ctx context.Context, req *contract.RegisterSetPasswordRequest) (*models.RegisterSetPasswordResponse, error) { + // Validate request + if req.RegistrationToken == "" { + return nil, fmt.Errorf("registration token is required") + } + if req.Password == "" { + return nil, fmt.Errorf("password is required") + } + if req.ConfirmPassword == "" { + return nil, fmt.Errorf("confirm password is required") + } + + // Validate password strength + if len(req.Password) < 8 { + return nil, fmt.Errorf("password must be at least 8 characters long") + } + + response, err := s.customerAuthProcessor.SetPassword(ctx, req) + if err != nil { + return nil, fmt.Errorf("failed to set password: %w", err) + } + + return response, nil +} + +func (s *customerAuthService) Login(ctx context.Context, req *contract.CustomerLoginRequest) (*models.CustomerLoginResponse, error) { + // Validate request + if req.PhoneNumber == "" { + return nil, fmt.Errorf("phone number is required") + } + if req.Password == "" { + return nil, fmt.Errorf("password is required") + } + + response, err := s.customerAuthProcessor.Login(ctx, req) + if err != nil { + return nil, fmt.Errorf("failed to login: %w", err) + } + + return response, nil +} + +func (s *customerAuthService) ResendOtp(ctx context.Context, req *contract.ResendOtpRequest) (*models.ResendOtpResponse, error) { + return s.customerAuthProcessor.ResendOtp(ctx, req) +} diff --git a/internal/util/jwt_util.go b/internal/util/jwt_util.go new file mode 100644 index 0000000..50df276 --- /dev/null +++ b/internal/util/jwt_util.go @@ -0,0 +1,87 @@ +package util + +import ( + "fmt" + "time" + + "apskel-pos-be/internal/entities" + + "github.com/golang-jwt/jwt/v5" +) + +// GenerateCustomerTokens generates access and refresh tokens for customer +func GenerateCustomerTokens(customer *entities.Customer, secret string, tokenTTLMinutes int) (string, string, time.Time, error) { + now := time.Now() + expiresAt := now.Add(time.Duration(tokenTTLMinutes) * time.Minute) + + // Create access token claims + accessClaims := jwt.MapClaims{ + "customer_id": customer.ID.String(), + "phone_number": *customer.PhoneNumber, + "name": customer.Name, + "type": "access", + "iat": now.Unix(), + "exp": expiresAt.Unix(), + } + + // Create refresh token claims (longer expiration) + refreshExpiresAt := now.Add(time.Duration(tokenTTLMinutes*7) * 24 * time.Hour) // 7 days + refreshClaims := jwt.MapClaims{ + "customer_id": customer.ID.String(), + "phone_number": *customer.PhoneNumber, + "type": "refresh", + "iat": now.Unix(), + "exp": refreshExpiresAt.Unix(), + } + + // Generate access token + accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims) + accessTokenString, err := accessToken.SignedString([]byte(secret)) + if err != nil { + return "", "", time.Time{}, fmt.Errorf("failed to generate access token: %w", err) + } + + // Generate refresh token + refreshToken := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims) + refreshTokenString, err := refreshToken.SignedString([]byte(secret)) + if err != nil { + return "", "", time.Time{}, fmt.Errorf("failed to generate refresh token: %w", err) + } + + return accessTokenString, refreshTokenString, expiresAt, nil +} + +// ValidateCustomerToken validates a customer JWT token +func ValidateCustomerToken(tokenString, secret string) (*jwt.Token, error) { + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return []byte(secret), nil + }) + + if err != nil { + return nil, fmt.Errorf("failed to parse token: %w", err) + } + + if !token.Valid { + return nil, fmt.Errorf("invalid token") + } + + return token, nil +} + +// ExtractCustomerIDFromToken extracts customer ID from JWT token +func ExtractCustomerIDFromToken(token *jwt.Token) (string, error) { + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return "", fmt.Errorf("invalid token claims") + } + + customerID, ok := claims["customer_id"].(string) + if !ok { + return "", fmt.Errorf("customer_id not found in token") + } + + return customerID, nil +} diff --git a/internal/validator/customer_auth_validator.go b/internal/validator/customer_auth_validator.go new file mode 100644 index 0000000..7497eab --- /dev/null +++ b/internal/validator/customer_auth_validator.go @@ -0,0 +1,236 @@ +package validator + +import ( + "errors" + "regexp" + "strings" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/contract" +) + +type CustomerAuthValidator interface { + ValidateCheckPhoneRequest(req *contract.CheckPhoneRequest) (error, string) + ValidateRegisterStartRequest(req *contract.RegisterStartRequest) (error, string) + ValidateRegisterVerifyOtpRequest(req *contract.RegisterVerifyOtpRequest) (error, string) + ValidateRegisterSetPasswordRequest(req *contract.RegisterSetPasswordRequest) (error, string) + ValidateCustomerLoginRequest(req *contract.CustomerLoginRequest) (error, string) + ValidateResendOtpRequest(req *contract.ResendOtpRequest) (error, string) +} + +type CustomerAuthValidatorImpl struct{} + +func NewCustomerAuthValidator() CustomerAuthValidator { + return &CustomerAuthValidatorImpl{} +} + +func (v *CustomerAuthValidatorImpl) ValidateCheckPhoneRequest(req *contract.CheckPhoneRequest) (error, string) { + if req == nil { + return errors.New("request is required"), constants.ValidationErrorCode + } + + // Validate phone number + if strings.TrimSpace(req.PhoneNumber) == "" { + return errors.New("phone number is required"), constants.ValidationErrorCode + } + + if !v.isValidPhoneNumber(req.PhoneNumber) { + return errors.New("invalid phone number format"), constants.ValidationErrorCode + } + + return nil, "" +} + +func (v *CustomerAuthValidatorImpl) ValidateRegisterStartRequest(req *contract.RegisterStartRequest) (error, string) { + if req == nil { + return errors.New("request is required"), constants.ValidationErrorCode + } + + // Validate phone number + if strings.TrimSpace(req.PhoneNumber) == "" { + return errors.New("phone number is required"), constants.ValidationErrorCode + } + + if !v.isValidPhoneNumber(req.PhoneNumber) { + return errors.New("invalid phone number format"), constants.ValidationErrorCode + } + + // Validate name + if strings.TrimSpace(req.Name) == "" { + return errors.New("name is required"), constants.ValidationErrorCode + } + + if len(req.Name) < 2 { + return errors.New("name must be at least 2 characters long"), constants.ValidationErrorCode + } + + if len(req.Name) > 100 { + return errors.New("name cannot exceed 100 characters"), constants.ValidationErrorCode + } + + // Validate birth date + if strings.TrimSpace(req.BirthDate) == "" { + return errors.New("birth date is required"), constants.ValidationErrorCode + } + + if !v.isValidDateFormat(req.BirthDate) { + return errors.New("invalid birth date format (YYYY-MM-DD)"), constants.ValidationErrorCode + } + + return nil, "" +} + +func (v *CustomerAuthValidatorImpl) ValidateRegisterVerifyOtpRequest(req *contract.RegisterVerifyOtpRequest) (error, string) { + if req == nil { + return errors.New("request is required"), constants.ValidationErrorCode + } + + // Validate registration token + if strings.TrimSpace(req.RegistrationToken) == "" { + return errors.New("registration token is required"), constants.ValidationErrorCode + } + + // Validate OTP code + if strings.TrimSpace(req.OtpCode) == "" { + return errors.New("OTP code is required"), constants.ValidationErrorCode + } + + if !v.isValidOtpCode(req.OtpCode) { + return errors.New("invalid OTP code format"), constants.ValidationErrorCode + } + + return nil, "" +} + +func (v *CustomerAuthValidatorImpl) ValidateRegisterSetPasswordRequest(req *contract.RegisterSetPasswordRequest) (error, string) { + if req == nil { + return errors.New("request is required"), constants.ValidationErrorCode + } + + // Validate registration token + if strings.TrimSpace(req.RegistrationToken) == "" { + return errors.New("registration token is required"), constants.ValidationErrorCode + } + + // Validate password + if strings.TrimSpace(req.Password) == "" { + return errors.New("password is required"), constants.ValidationErrorCode + } + + if len(req.Password) < 8 { + return errors.New("password must be at least 8 characters long"), constants.ValidationErrorCode + } + + if len(req.Password) > 128 { + return errors.New("password cannot exceed 128 characters"), constants.ValidationErrorCode + } + + // Validate confirm password + if strings.TrimSpace(req.ConfirmPassword) == "" { + return errors.New("confirm password is required"), constants.ValidationErrorCode + } + + if req.Password != req.ConfirmPassword { + return errors.New("passwords do not match"), constants.ValidationErrorCode + } + + // Validate password strength + if !v.isStrongPassword(req.Password) { + return errors.New("password must contain at least one uppercase letter, one lowercase letter, and one number"), constants.ValidationErrorCode + } + + return nil, "" +} + +func (v *CustomerAuthValidatorImpl) ValidateCustomerLoginRequest(req *contract.CustomerLoginRequest) (error, string) { + if req == nil { + return errors.New("request is required"), constants.ValidationErrorCode + } + + // Validate phone number + if strings.TrimSpace(req.PhoneNumber) == "" { + return errors.New("phone number is required"), constants.ValidationErrorCode + } + + if !v.isValidPhoneNumber(req.PhoneNumber) { + return errors.New("invalid phone number format"), constants.ValidationErrorCode + } + + // Validate password + if strings.TrimSpace(req.Password) == "" { + return errors.New("password is required"), constants.ValidationErrorCode + } + + return nil, "" +} + +// Helper validation functions +func (v *CustomerAuthValidatorImpl) isValidPhoneNumber(phoneNumber string) bool { + // Basic phone number validation - adjust regex based on your requirements + phoneRegex := regexp.MustCompile(`^\+?[1-9]\d{1,14}$`) + return phoneRegex.MatchString(phoneNumber) +} + +func (v *CustomerAuthValidatorImpl) isValidDateFormat(date string) bool { + // Basic date format validation for YYYY-MM-DD + dateRegex := regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`) + if !dateRegex.MatchString(date) { + return false + } + + // You can add more sophisticated date validation here if needed + return true +} + +func (v *CustomerAuthValidatorImpl) isValidOtpCode(code string) bool { + // OTP code should be 4-8 digits + otpRegex := regexp.MustCompile(`^\d{4,8}$`) + return otpRegex.MatchString(code) +} + +func (v *CustomerAuthValidatorImpl) ValidateResendOtpRequest(req *contract.ResendOtpRequest) (error, string) { + if req == nil { + return errors.New("request is required"), constants.CustomerEntity + } + + // Validate phone number + if req.PhoneNumber == "" { + return errors.New("phone number is required"), constants.CustomerEntity + } + + // Validate phone number format + if !v.isValidPhoneNumber(req.PhoneNumber) { + return errors.New("invalid phone number format"), constants.CustomerEntity + } + + // Validate purpose + if req.Purpose == "" { + return errors.New("purpose is required"), constants.CustomerEntity + } + + // Validate purpose values + validPurposes := []string{"login", "registration"} + if !v.contains(validPurposes, req.Purpose) { + return errors.New("purpose must be either 'login' or 'registration'"), constants.CustomerEntity + } + + return nil, "" +} + +func (v *CustomerAuthValidatorImpl) isStrongPassword(password string) bool { + // Password must contain at least one uppercase, one lowercase, and one number + hasUpper := regexp.MustCompile(`[A-Z]`).MatchString(password) + hasLower := regexp.MustCompile(`[a-z]`).MatchString(password) + hasNumber := regexp.MustCompile(`[0-9]`).MatchString(password) + + return hasUpper && hasLower && hasNumber +} + +func (v *CustomerAuthValidatorImpl) contains(slice []string, item string) bool { + for _, s := range slice { + if s == item { + return true + } + } + return false +} diff --git a/migrations/000057_add_customer_password_field.down.sql b/migrations/000057_add_customer_password_field.down.sql new file mode 100644 index 0000000..9c1d2ec --- /dev/null +++ b/migrations/000057_add_customer_password_field.down.sql @@ -0,0 +1,8 @@ +-- Remove indexes +DROP INDEX IF EXISTS idx_customers_password_hash; +DROP INDEX IF EXISTS idx_customers_phone_number; + +-- Remove added columns +ALTER TABLE customers DROP COLUMN IF EXISTS birth_date; +ALTER TABLE customers DROP COLUMN IF EXISTS phone_number; +ALTER TABLE customers DROP COLUMN IF EXISTS password_hash; diff --git a/migrations/000058_create_otp_sessions_table.down.sql b/migrations/000058_create_otp_sessions_table.down.sql new file mode 100644 index 0000000..41ffa33 --- /dev/null +++ b/migrations/000058_create_otp_sessions_table.down.sql @@ -0,0 +1,9 @@ +-- Drop indexes +DROP INDEX IF EXISTS idx_otp_sessions_is_used; +DROP INDEX IF EXISTS idx_otp_sessions_expires_at; +DROP INDEX IF EXISTS idx_otp_sessions_purpose; +DROP INDEX IF EXISTS idx_otp_sessions_phone_number; +DROP INDEX IF EXISTS idx_otp_sessions_token; + +-- Drop OTP sessions table +DROP TABLE IF EXISTS otp_sessions; diff --git a/migrations/000058_create_otp_sessions_table.up.sql b/migrations/000058_create_otp_sessions_table.up.sql new file mode 100644 index 0000000..c753c1e --- /dev/null +++ b/migrations/000058_create_otp_sessions_table.up.sql @@ -0,0 +1,32 @@ +-- Create OTP sessions table for storing OTP codes and validation +CREATE TABLE otp_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + token VARCHAR(255) NOT NULL UNIQUE, + code VARCHAR(10) NOT NULL, + phone_number VARCHAR(20) NOT NULL, + purpose VARCHAR(50) NOT NULL, -- 'login', 'registration', etc. + expires_at TIMESTAMP NOT NULL, + is_used BOOLEAN DEFAULT false, + attempts_count INT DEFAULT 0, -- Track failed attempts + max_attempts INT DEFAULT 3, -- Maximum allowed attempts + created_at TIMESTAMP DEFAULT now(), + updated_at TIMESTAMP DEFAULT now() +); + +-- Create indexes for better performance +CREATE INDEX idx_otp_sessions_token ON otp_sessions(token); +CREATE INDEX idx_otp_sessions_phone_number ON otp_sessions(phone_number); +CREATE INDEX idx_otp_sessions_purpose ON otp_sessions(purpose); +CREATE INDEX idx_otp_sessions_expires_at ON otp_sessions(expires_at); +CREATE INDEX idx_otp_sessions_is_used ON otp_sessions(is_used); + +-- Add comments +COMMENT ON TABLE otp_sessions IS 'OTP sessions for authentication and registration'; +COMMENT ON COLUMN otp_sessions.token IS 'Unique token for OTP session'; +COMMENT ON COLUMN otp_sessions.code IS 'OTP code (6-10 digits)'; +COMMENT ON COLUMN otp_sessions.phone_number IS 'Target phone number for OTP'; +COMMENT ON COLUMN otp_sessions.purpose IS 'Purpose of OTP: login, registration, etc.'; +COMMENT ON COLUMN otp_sessions.expires_at IS 'OTP expiration timestamp'; +COMMENT ON COLUMN otp_sessions.is_used IS 'Whether OTP has been used'; +COMMENT ON COLUMN otp_sessions.attempts_count IS 'Number of failed validation attempts'; +COMMENT ON COLUMN otp_sessions.max_attempts IS 'Maximum allowed validation attempts';