apskel-pos-backend/internal/processor/customer_auth_processor.go
Aditya Siregar be92ec8b23 test wheels
2025-09-18 12:01:20 +07:00

441 lines
14 KiB
Go

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
}
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,
}
}
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 data in OTP session metadata
registrationData := map[string]interface{}{
"registration_token": registrationToken,
"name": req.Name,
"birth_date": req.BirthDate,
"step": "otp_sent",
}
// Update OTP session with registration metadata
otpSession.Metadata = registrationData
if err := p.otpRepo.UpdateOtpSession(ctx, otpSession); err != nil {
return nil, fmt.Errorf("failed to update OTP session with registration data: %w", err)
}
// 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) {
otpSession, err := p.otpRepo.GetOtpSessionByRegistrationToken(ctx, req.RegistrationToken)
if err != nil {
return nil, fmt.Errorf("failed to get OTP session: %w", err)
}
if otpSession == nil {
return nil, fmt.Errorf("invalid or expired registration token")
}
if otpSession.IsExpired() {
return nil, fmt.Errorf("registration token expired")
}
if !p.otpProcessor.ValidateOtpCode(req.OtpCode) {
return &models.RegisterVerifyOtpResponse{
Status: "FAILED",
Message: "Invalid OTP format.",
}, nil
}
if otpSession.Code != req.OtpCode {
otpSession.IncrementAttempts()
if err := p.otpRepo.UpdateOtpSession(ctx, otpSession); err != nil {
fmt.Printf("Warning: failed to update OTP session attempts: %v\n", err)
}
return &models.RegisterVerifyOtpResponse{
Status: "FAILED",
Message: "Invalid OTP code.",
}, nil
}
if otpSession.IsUsed || otpSession.IsMaxAttemptsReached() {
return &models.RegisterVerifyOtpResponse{
Status: "FAILED",
Message: "OTP code already used or max attempts reached.",
}, nil
}
// Mark OTP as used
otpSession.MarkAsUsed()
// Update registration step in metadata
if otpSession.Metadata == nil {
otpSession.Metadata = make(map[string]interface{})
}
otpSession.Metadata["step"] = "otp_verified"
if err := p.otpRepo.UpdateOtpSession(ctx, otpSession); err != nil {
return &models.RegisterVerifyOtpResponse{
Status: "FAILED",
Message: "Failed to update OTP session.",
}, nil
}
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) {
if req.Password != req.ConfirmPassword {
return nil, fmt.Errorf("passwords do not match")
}
// Get OTP session by registration token from metadata
otpSession, err := p.otpRepo.GetOtpSessionByRegistrationToken(ctx, req.RegistrationToken)
if err != nil {
return nil, fmt.Errorf("failed to get OTP session: %w", err)
}
if otpSession == nil {
return nil, fmt.Errorf("invalid or expired registration token")
}
if otpSession.IsExpired() {
return nil, fmt.Errorf("registration token expired")
}
step, ok := otpSession.Metadata["step"].(string)
if !ok || 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)
// Extract registration data from OTP session metadata
name, ok := otpSession.Metadata["name"].(string)
if !ok {
return nil, fmt.Errorf("invalid registration data: name not found")
}
birthDateStr, ok := otpSession.Metadata["birth_date"].(string)
if !ok {
return nil, fmt.Errorf("invalid registration data: birth_date not found")
}
// Parse birth date
birthDate, err := time.Parse("2006-01-02", birthDateStr)
if err != nil {
return nil, fmt.Errorf("invalid birth date format: %w", err)
}
defaultOrgID := uuid.MustParse("87bec7c1-e274-4f66-bac5-84e632208470") // This should be configurable
customer := &entities.Customer{
OrganizationID: defaultOrgID,
Name: name,
PhoneNumber: &otpSession.PhoneNumber,
BirthDate: &birthDate,
PasswordHash: &passwordHashStr,
IsActive: true,
}
if err := p.customerAuthRepo.CreateCustomer(ctx, customer); err != nil {
return nil, fmt.Errorf("failed to create customer: %w", err)
}
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: birthDate.Format("2006-01-02"),
},
},
}, 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