239 lines
7.3 KiB
Go
239 lines
7.3 KiB
Go
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
|
|
}
|