Files
apskel-pos-backend/internal/processor/customer_auth_processor.go
efrilmandClaude Opus 5.5 8bf2fe5585 fix(customer-auth): make organization_id optional at registration
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>
2026-09-30 18:16:31 +07:00

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