user auth register

This commit is contained in:
Aditya Siregar 2025-09-18 01:32:01 +07:00
parent c68b536480
commit 65f61b65cf
24 changed files with 2065 additions and 14 deletions

View File

@ -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
View 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
}

View File

@ -2,9 +2,14 @@ package config
type Jwt struct {
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"`
}

View File

@ -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
@ -32,3 +35,8 @@ s3:
log:
log_format: 'json'
log_level: 'debug'
fonnte:
api_url: "https://api.fonnte.com/send"
token: "bADQrf9NTXfLZQCK2wGg"
timeout: 30

View File

@ -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(),
}
}

View 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
}

View File

@ -53,6 +53,7 @@ const (
RewardEntity = "reward"
CampaignEntity = "campaign"
CampaignRuleEntity = "campaign_rule"
CustomerEntity = "customer"
)
var HttpErrorMap = map[string]int{

View 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"
}

View File

@ -13,7 +13,10 @@ type Customer struct {
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"`

View File

@ -34,6 +34,7 @@ func GetAllEntities() []interface{} {
&Reward{},
&Campaign{},
&CampaignRule{},
&OtpSession{},
// Analytics entities are not database tables, they are query results
}
}

View 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
}

View 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")
}

View 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"
}

View 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: &registrationSession.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

View 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
}

View 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
}

View 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
}

View File

@ -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)

View 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
View 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
}

View 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
}

View 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;

View 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;

View 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';