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>
This commit is contained in:
efrilm
2026-09-30 18:16:31 +07:00
co-authored by Claude Opus 5.5
parent a3cb7dd5fd
commit 8bf2fe5585
4 changed files with 55 additions and 14 deletions
+3 -2
View File
@@ -17,8 +17,9 @@ type RegisterStartRequest struct {
Name string `json:"name" binding:"required"` Name string `json:"name" binding:"required"`
BirthDate string `json:"birth_date" binding:"required"` BirthDate string `json:"birth_date" binding:"required"`
// The organization (brand) the customer registers with. A customer belongs to one // The organization (brand) the customer registers with. A customer belongs to one
// organization; the app sends the one it is built for. // organization. Optional: when it is left out and the database has exactly one
OrganizationID string `json:"organization_id" binding:"required"` // organization, the customer joins that one.
OrganizationID string `json:"organization_id,omitempty"`
} }
type RegisterVerifyOtpRequest struct { type RegisterVerifyOtpRequest struct {
+37 -10
View File
@@ -143,19 +143,12 @@ func (p *customerAuthProcessor) StartRegistration(ctx context.Context, req *cont
return nil, fmt.Errorf("phone number already registered") return nil, fmt.Errorf("phone number already registered")
} }
// The customer joins the organization the app is built for. Check it exists now, // Resolve the organization before an OTP is sent, rather than failing on a foreign
// before an OTP is sent, rather than failing on a foreign key at the last step. // key at the last step.
organizationID, err := uuid.Parse(strings.TrimSpace(req.OrganizationID)) organizationID, err := p.registrationOrganization(ctx, req.OrganizationID)
if err != nil {
return nil, fmt.Errorf("organization_id must be a valid UUID")
}
orgExists, err := p.customerAuthRepo.OrganizationExists(ctx, organizationID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if !orgExists {
return nil, fmt.Errorf("organization not found")
}
// Generate registration token and create OTP session // Generate registration token and create OTP session
registrationToken := uuid.New().String() registrationToken := uuid.New().String()
@@ -458,3 +451,37 @@ func (p *customerAuthProcessor) ResendOtp(ctx context.Context, req *contract.Res
} }
// Helper functions - OTP generation is now handled by OtpProcessor // 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")
}
}
@@ -19,6 +19,8 @@ type CustomerAuthRepository interface {
SetCustomerPassword(ctx context.Context, customerID string, passwordHash string) error SetCustomerPassword(ctx context.Context, customerID string, passwordHash string) error
// OrganizationExists reports whether an organization with this id exists. // OrganizationExists reports whether an organization with this id exists.
OrganizationExists(ctx context.Context, organizationID uuid.UUID) (bool, error) OrganizationExists(ctx context.Context, organizationID uuid.UUID) (bool, error)
// OrganizationIDs returns up to limit organization ids.
OrganizationIDs(ctx context.Context, limit int) ([]uuid.UUID, error)
} }
type customerAuthRepository struct { type customerAuthRepository struct {
@@ -90,3 +92,12 @@ func (r *customerAuthRepository) OrganizationExists(ctx context.Context, organiz
} }
return count > 0, nil return count > 0, nil
} }
func (r *customerAuthRepository) OrganizationIDs(ctx context.Context, limit int) ([]uuid.UUID, error) {
var ids []uuid.UUID
err := r.db.WithContext(ctx).Table("organizations").Order("created_at").Limit(limit).Pluck("id", &ids).Error
if err != nil {
return nil, fmt.Errorf("failed to list organizations: %w", err)
}
return ids, nil
}
@@ -71,9 +71,11 @@ func (v *CustomerAuthValidatorImpl) ValidateRegisterStartRequest(req *contract.R
} }
// Validate organization // Validate organization
if _, err := uuid.Parse(strings.TrimSpace(req.OrganizationID)); err != nil { 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 return errors.New("organization_id must be a valid UUID"), constants.ValidationErrorCode
} }
}
// Validate birth date // Validate birth date
if strings.TrimSpace(req.BirthDate) == "" { if strings.TrimSpace(req.BirthDate) == "" {