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 }