Requiring organization_id broke the current app, which does not send it. When it is left out and the database has exactly one organization, the customer now joins that one, so the app works unchanged. A sent organization_id must still exist, and with several organizations and none sent registration is refused with a clear message. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
488 lines
15 KiB
Go
488 lines
15 KiB
Go
package processor
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"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")
|
|
}
|
|
|
|
// Resolve the organization before an OTP is sent, rather than failing on a foreign
|
|
// key at the last step.
|
|
organizationID, err := p.registrationOrganization(ctx, req.OrganizationID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// 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,
|
|
"organization_id": organizationID.String(),
|
|
"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)
|
|
}
|
|
|
|
orgIDStr, _ := otpSession.Metadata["organization_id"].(string)
|
|
organizationID, err := uuid.Parse(orgIDStr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid registration data: organization not found, start the registration again")
|
|
}
|
|
|
|
customer := &entities.Customer{
|
|
OrganizationID: organizationID,
|
|
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
|
|
|
|
// registrationOrganization is the organization a new customer joins: the one the app
|
|
// sent, which must exist, or, when the app sent none, the only organization there is.
|
|
// With several organizations and none sent there is no way to choose, so it refuses.
|
|
func (p *customerAuthProcessor) registrationOrganization(ctx context.Context, requested string) (uuid.UUID, error) {
|
|
requested = strings.TrimSpace(requested)
|
|
if requested != "" {
|
|
id, err := uuid.Parse(requested)
|
|
if err != nil {
|
|
return uuid.Nil, fmt.Errorf("organization_id must be a valid UUID")
|
|
}
|
|
exists, err := p.customerAuthRepo.OrganizationExists(ctx, id)
|
|
if err != nil {
|
|
return uuid.Nil, err
|
|
}
|
|
if !exists {
|
|
return uuid.Nil, fmt.Errorf("organization not found")
|
|
}
|
|
return id, nil
|
|
}
|
|
|
|
ids, err := p.customerAuthRepo.OrganizationIDs(ctx, 2)
|
|
if err != nil {
|
|
return uuid.Nil, err
|
|
}
|
|
switch len(ids) {
|
|
case 1:
|
|
return ids[0], nil
|
|
case 0:
|
|
return uuid.Nil, fmt.Errorf("no organization exists to register customers into")
|
|
default:
|
|
return uuid.Nil, fmt.Errorf("organization_id is required: there is more than one organization")
|
|
}
|
|
}
|