Files
apskel-pos-backend/internal/validator/customer_auth_validator.go
efrilmandClaude Opus 5.5 19afa50b9c feat(customers): store customer phone numbers as 62…
A customer's phone number (customers.phone_number, the one they log in with) has
one stored form, 62 followed by the number without its 0: 6281234561234.
util.NormalizePhoneNumber rewrites 0812…, +62 812…, 62812…, 812… and +62 0812…,
with spaces, dashes, dots or parentheses, to it, and refuses anything that is not
an Indonesian mobile number (628 and 7 to 11 more digits).

It is applied wherever a customer types their number: check-phone, register,
login and resend-OTP (the validator rewrites the request), and the transfer
recipient. Before, the number was matched as typed and a leading 0 was refused,
so the same customer written another way was not found. The masked recipient
stays 08**-****-1234 as customers write numbers.

Migration 000116 rewrites the numbers already stored in customers and
otp_sessions. When several become the same number, the customer that already has
it keeps it, else a registered one, else the oldest; the others, and numbers that
are not Indonesian mobile numbers, are left as they are and cannot log in until
fixed by hand (the query to find them is in the migration). Down does nothing.

The migration was run, twice, against Postgres with a reduced customers and
otp_sessions schema and sample numbers; not against the real schema. The
Postgres tests were not run. customers.phone (the POS contact number) is not
touched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:58:24 +07:00

252 lines
7.6 KiB
Go

package validator
import (
"errors"
"regexp"
"strings"
"github.com/google/uuid"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/util"
)
type CustomerAuthValidator interface {
ValidateCheckPhoneRequest(req *contract.CheckPhoneRequest) (error, string)
ValidateRegisterStartRequest(req *contract.RegisterStartRequest) (error, string)
ValidateRegisterVerifyOtpRequest(req *contract.RegisterVerifyOtpRequest) (error, string)
ValidateRegisterSetPasswordRequest(req *contract.RegisterSetPasswordRequest) (error, string)
ValidateCustomerLoginRequest(req *contract.CustomerLoginRequest) (error, string)
ValidateResendOtpRequest(req *contract.ResendOtpRequest) (error, string)
}
type CustomerAuthValidatorImpl struct{}
func NewCustomerAuthValidator() CustomerAuthValidator {
return &CustomerAuthValidatorImpl{}
}
func (v *CustomerAuthValidatorImpl) ValidateCheckPhoneRequest(req *contract.CheckPhoneRequest) (error, string) {
if req == nil {
return errors.New("request is required"), constants.ValidationErrorCode
}
// Validate phone number
if strings.TrimSpace(req.PhoneNumber) == "" {
return errors.New("phone number is required"), constants.ValidationErrorCode
}
if !v.normalizePhoneNumber(&req.PhoneNumber) {
return errors.New("invalid phone number format"), constants.ValidationErrorCode
}
return nil, ""
}
func (v *CustomerAuthValidatorImpl) ValidateRegisterStartRequest(req *contract.RegisterStartRequest) (error, string) {
if req == nil {
return errors.New("request is required"), constants.ValidationErrorCode
}
// Validate phone number
if strings.TrimSpace(req.PhoneNumber) == "" {
return errors.New("phone number is required"), constants.ValidationErrorCode
}
if !v.normalizePhoneNumber(&req.PhoneNumber) {
return errors.New("invalid phone number format"), constants.ValidationErrorCode
}
// Validate name
if strings.TrimSpace(req.Name) == "" {
return errors.New("name is required"), constants.ValidationErrorCode
}
if len(req.Name) < 2 {
return errors.New("name must be at least 2 characters long"), constants.ValidationErrorCode
}
if len(req.Name) > 100 {
return errors.New("name cannot exceed 100 characters"), constants.ValidationErrorCode
}
// Validate organization
if orgID := strings.TrimSpace(req.OrganizationID); orgID != "" {
if _, err := uuid.Parse(orgID); err != nil {
return errors.New("organization_id must be a valid UUID"), constants.ValidationErrorCode
}
}
// Validate birth date
if strings.TrimSpace(req.BirthDate) == "" {
return errors.New("birth date is required"), constants.ValidationErrorCode
}
if !v.isValidDateFormat(req.BirthDate) {
return errors.New("invalid birth date format (YYYY-MM-DD)"), constants.ValidationErrorCode
}
return nil, ""
}
func (v *CustomerAuthValidatorImpl) ValidateRegisterVerifyOtpRequest(req *contract.RegisterVerifyOtpRequest) (error, string) {
if req == nil {
return errors.New("request is required"), constants.ValidationErrorCode
}
// Validate registration token
if strings.TrimSpace(req.RegistrationToken) == "" {
return errors.New("registration token is required"), constants.ValidationErrorCode
}
// Validate OTP code
if strings.TrimSpace(req.OtpCode) == "" {
return errors.New("OTP code is required"), constants.ValidationErrorCode
}
if !v.isValidOtpCode(req.OtpCode) {
return errors.New("invalid OTP code format"), constants.ValidationErrorCode
}
return nil, ""
}
func (v *CustomerAuthValidatorImpl) ValidateRegisterSetPasswordRequest(req *contract.RegisterSetPasswordRequest) (error, string) {
if req == nil {
return errors.New("request is required"), constants.ValidationErrorCode
}
// Validate registration token
if strings.TrimSpace(req.RegistrationToken) == "" {
return errors.New("registration token is required"), constants.ValidationErrorCode
}
// Validate password
if strings.TrimSpace(req.Password) == "" {
return errors.New("password is required"), constants.ValidationErrorCode
}
if len(req.Password) < 8 {
return errors.New("password must be at least 8 characters long"), constants.ValidationErrorCode
}
if len(req.Password) > 128 {
return errors.New("password cannot exceed 128 characters"), constants.ValidationErrorCode
}
// Validate confirm password
if strings.TrimSpace(req.ConfirmPassword) == "" {
return errors.New("confirm password is required"), constants.ValidationErrorCode
}
if req.Password != req.ConfirmPassword {
return errors.New("passwords do not match"), constants.ValidationErrorCode
}
// Validate password strength
if !v.isStrongPassword(req.Password) {
return errors.New("password must contain at least one uppercase letter, one lowercase letter, and one number"), constants.ValidationErrorCode
}
return nil, ""
}
func (v *CustomerAuthValidatorImpl) ValidateCustomerLoginRequest(req *contract.CustomerLoginRequest) (error, string) {
if req == nil {
return errors.New("request is required"), constants.ValidationErrorCode
}
// Validate phone number
if strings.TrimSpace(req.PhoneNumber) == "" {
return errors.New("phone number is required"), constants.ValidationErrorCode
}
if !v.normalizePhoneNumber(&req.PhoneNumber) {
return errors.New("invalid phone number format"), constants.ValidationErrorCode
}
// Validate password
if strings.TrimSpace(req.Password) == "" {
return errors.New("password is required"), constants.ValidationErrorCode
}
return nil, ""
}
// Helper validation functions
// normalizePhoneNumber rewrites the phone number in the form it is stored in, 62…
// (util.NormalizePhoneNumber), and reports false when it is not a valid one.
func (v *CustomerAuthValidatorImpl) normalizePhoneNumber(phoneNumber *string) bool {
normalized, err := util.NormalizePhoneNumber(*phoneNumber)
if err != nil {
return false
}
*phoneNumber = normalized
return true
}
func (v *CustomerAuthValidatorImpl) isValidDateFormat(date string) bool {
// Basic date format validation for YYYY-MM-DD
dateRegex := regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
if !dateRegex.MatchString(date) {
return false
}
// You can add more sophisticated date validation here if needed
return true
}
func (v *CustomerAuthValidatorImpl) isValidOtpCode(code string) bool {
// OTP code should be 4-8 digits
otpRegex := regexp.MustCompile(`^\d{4,8}$`)
return otpRegex.MatchString(code)
}
func (v *CustomerAuthValidatorImpl) ValidateResendOtpRequest(req *contract.ResendOtpRequest) (error, string) {
if req == nil {
return errors.New("request is required"), constants.CustomerEntity
}
// Validate phone number
if req.PhoneNumber == "" {
return errors.New("phone number is required"), constants.CustomerEntity
}
// Validate phone number format
if !v.normalizePhoneNumber(&req.PhoneNumber) {
return errors.New("invalid phone number format"), constants.CustomerEntity
}
// Validate purpose
if req.Purpose == "" {
return errors.New("purpose is required"), constants.CustomerEntity
}
// Validate purpose values
validPurposes := []string{"login", "registration"}
if !v.contains(validPurposes, req.Purpose) {
return errors.New("purpose must be either 'login' or 'registration'"), constants.CustomerEntity
}
return nil, ""
}
func (v *CustomerAuthValidatorImpl) isStrongPassword(password string) bool {
// Password must contain at least one uppercase, one lowercase, and one number
hasUpper := regexp.MustCompile(`[A-Z]`).MatchString(password)
hasLower := regexp.MustCompile(`[a-z]`).MatchString(password)
hasNumber := regexp.MustCompile(`[0-9]`).MatchString(password)
return hasUpper && hasLower && hasNumber
}
func (v *CustomerAuthValidatorImpl) contains(slice []string, item string) bool {
for _, s := range slice {
if s == item {
return true
}
}
return false
}