user auth register
This commit is contained in:
parent
c68b536480
commit
65f61b65cf
@ -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
|
||||
}
|
||||
|
||||
19
config/fonnte.go
Normal file
19
config/fonnte.go
Normal file
@ -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
|
||||
}
|
||||
@ -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"`
|
||||
}
|
||||
|
||||
@ -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'
|
||||
log_level: 'debug'
|
||||
|
||||
fonnte:
|
||||
api_url: "https://api.fonnte.com/send"
|
||||
token: "bADQrf9NTXfLZQCK2wGg"
|
||||
timeout: 30
|
||||
@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
77
internal/client/fonnte_client.go
Normal file
77
internal/client/fonnte_client.go
Normal file
@ -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
|
||||
}
|
||||
@ -53,6 +53,7 @@ const (
|
||||
RewardEntity = "reward"
|
||||
CampaignEntity = "campaign"
|
||||
CampaignRuleEntity = "campaign_rule"
|
||||
CustomerEntity = "customer"
|
||||
)
|
||||
|
||||
var HttpErrorMap = map[string]int{
|
||||
|
||||
144
internal/contract/customer_auth_contract.go
Normal file
144
internal/contract/customer_auth_contract.go
Normal file
@ -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"
|
||||
}
|
||||
@ -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"`
|
||||
|
||||
@ -34,6 +34,7 @@ func GetAllEntities() []interface{} {
|
||||
&Reward{},
|
||||
&Campaign{},
|
||||
&CampaignRule{},
|
||||
&OtpSession{},
|
||||
// Analytics entities are not database tables, they are query results
|
||||
}
|
||||
}
|
||||
|
||||
53
internal/entities/otp_session.go
Normal file
53
internal/entities/otp_session.go
Normal file
@ -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
|
||||
}
|
||||
196
internal/handler/customer_auth_handler.go
Normal file
196
internal/handler/customer_auth_handler.go
Normal file
@ -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")
|
||||
}
|
||||
144
internal/models/customer_auth.go
Normal file
144
internal/models/customer_auth.go
Normal file
@ -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"
|
||||
}
|
||||
436
internal/processor/customer_auth_processor.go
Normal file
436
internal/processor/customer_auth_processor.go
Normal file
@ -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
|
||||
238
internal/processor/otp_processor.go
Normal file
238
internal/processor/otp_processor.go
Normal file
@ -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
|
||||
}
|
||||
80
internal/repository/customer_auth_repository.go
Normal file
80
internal/repository/customer_auth_repository.go
Normal file
@ -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
|
||||
}
|
||||
103
internal/repository/otp_repository.go
Normal file
103
internal/repository/otp_repository.go
Normal file
@ -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
|
||||
}
|
||||
@ -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)
|
||||
|
||||
126
internal/service/customer_auth_service.go
Normal file
126
internal/service/customer_auth_service.go
Normal file
@ -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)
|
||||
}
|
||||
87
internal/util/jwt_util.go
Normal file
87
internal/util/jwt_util.go
Normal file
@ -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
|
||||
}
|
||||
236
internal/validator/customer_auth_validator.go
Normal file
236
internal/validator/customer_auth_validator.go
Normal file
@ -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
|
||||
}
|
||||
8
migrations/000057_add_customer_password_field.down.sql
Normal file
8
migrations/000057_add_customer_password_field.down.sql
Normal file
@ -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;
|
||||
9
migrations/000058_create_otp_sessions_table.down.sql
Normal file
9
migrations/000058_create_otp_sessions_table.down.sql
Normal file
@ -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;
|
||||
32
migrations/000058_create_otp_sessions_table.up.sql
Normal file
32
migrations/000058_create_otp_sessions_table.up.sql
Normal file
@ -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';
|
||||
Loading…
x
Reference in New Issue
Block a user