package handler import ( "baron-sso-backend/internal/domain" "context" "fmt" "log/slog" "slices" "strconv" "strings" ) const hanmacFamilyTenantSlug = "hanmac-family" type hanmacEmailScope struct { TenantIDs map[string]bool Slugs map[string]bool IDList []string SlugList []string } type hanmacEmailEvaluation struct { Email string OriginalEmail string SuggestedEmail string Status string Warnings []string Message string Blocking bool LocalPart string } type hanmacLocalPartOwner struct { UserID string Email string Name string } func (h *UserHandler) evaluateHanmacImportEmail(ctx context.Context, item bulkUserItem, scope *hanmacEmailScope, usedLocalParts map[string]hanmacLocalPartOwner) hanmacEmailEvaluation { originalEmail := strings.TrimSpace(item.Email) name := strings.TrimSpace(item.Name) evaluation := hanmacEmailEvaluation{ Email: originalEmail, OriginalEmail: originalEmail, Status: "valid", } localPart, domainPart, err := domain.SplitEmailDomain(originalEmail) if err != nil { evaluation.Status = "blockingError" evaluation.Message = "invalid email format" evaluation.Blocking = true return evaluation } base, needsReview, _ := domain.BuildKoreanNameEmailBase(name) if needsReview { evaluation.Warnings = append(evaluation.Warnings, "needsReview") evaluation.Status = "needsReview" } if localPart == "" { if base == "" { evaluation.Status = "blockingError" evaluation.Message = "이름으로 이메일 ID를 제안할 수 없습니다." evaluation.Blocking = true return evaluation } nextLocalPart := nextAvailableHanmacLocalPart(base, usedLocalParts) evaluation.Email = nextLocalPart + "@" + domainPart evaluation.SuggestedEmail = evaluation.Email evaluation.LocalPart = nextLocalPart evaluation.Status = "suggested" evaluation.Warnings = appendUniqueString(evaluation.Warnings, "suggested") return evaluation } evaluation.LocalPart = localPart if owner, exists := usedLocalParts[localPart]; exists { evaluation.Status = "blockingError" evaluation.Message = formatHanmacLocalPartConflictMessage(localPart, owner) evaluation.Blocking = true return evaluation } if base != "" && !domain.MatchesSuggestedNameRule(localPart, base) { evaluation.Status = "ruleMismatch" evaluation.Warnings = appendUniqueString(evaluation.Warnings, "ruleMismatch") } if evaluation.Status == "needsReview" && len(evaluation.Warnings) == 0 { evaluation.Warnings = append(evaluation.Warnings, "needsReview") } _ = scope return evaluation } func (h *UserHandler) ensureHanmacCreateEmailAllowed(ctx context.Context, email string, tenantSlug string, tenantID string) error { return h.ensureHanmacEmailAllowedWithLog(ctx, email, tenantSlug, tenantID, "", "hanmac create email local-part conflict") } func (h *UserHandler) ensureHanmacEmailAllowed(ctx context.Context, email string, tenantSlug string, tenantID string, currentUserID string) error { return h.ensureHanmacEmailAllowedWithLog(ctx, email, tenantSlug, tenantID, currentUserID, "hanmac email local-part conflict") } func (h *UserHandler) ensureHanmacEmailAllowedWithLog(ctx context.Context, email string, tenantSlug string, tenantID string, currentUserID string, logMessage string) error { scope, err := h.resolveHanmacEmailScope(ctx) if err != nil || scope == nil || !scope.ContainsTenant(tenantID, tenantSlug) { return nil } localPart, err := domain.ExtractNormalizedEmailLocalPart(email) if err != nil { return err } usedLocalParts, err := h.loadHanmacLocalParts(ctx, scope) if err != nil { return err } if owner, exists := usedLocalParts[localPart]; exists { ownerUserID := strings.TrimSpace(owner.UserID) if currentUserID != "" && ownerUserID != "" && ownerUserID == strings.TrimSpace(currentUserID) { return nil } slog.Warn( logMessage, "requestedEmail", email, "localPart", localPart, "ownerUserID", owner.UserID, "ownerEmail", owner.Email, "ownerName", owner.Name, "tenantID", tenantID, "tenantSlug", tenantSlug, ) return fmt.Errorf("%s", formatHanmacLocalPartConflictMessage(localPart, owner)) } return nil } func (h *UserHandler) resolveHanmacEmailScope(ctx context.Context) (*hanmacEmailScope, error) { if h.TenantService == nil { return nil, nil } tenants, _, err := h.TenantService.ListTenants(ctx, 10000, 0, "", "") if err != nil { return nil, err } var rootID string for _, tenant := range tenants { if strings.EqualFold(strings.TrimSpace(tenant.Slug), hanmacFamilyTenantSlug) { rootID = tenant.ID break } } if rootID == "" { return nil, nil } tenantByID := make(map[string]domain.Tenant, len(tenants)) for _, tenant := range tenants { tenantByID[tenant.ID] = tenant } scope := &hanmacEmailScope{ TenantIDs: make(map[string]bool), Slugs: make(map[string]bool), } for _, tenant := range tenants { if isTenantDescendantOf(tenant, rootID, tenantByID) { scope.TenantIDs[tenant.ID] = true scope.Slugs[strings.ToLower(strings.TrimSpace(tenant.Slug))] = true scope.IDList = append(scope.IDList, tenant.ID) scope.SlugList = append(scope.SlugList, tenant.Slug) } } return scope, nil } func (h *UserHandler) loadHanmacLocalParts(ctx context.Context, scope *hanmacEmailScope) (map[string]hanmacLocalPartOwner, error) { used := make(map[string]hanmacLocalPartOwner) if h.UserRepo == nil || scope == nil { return used, nil } if len(scope.IDList) > 0 { users, err := h.UserRepo.FindByTenantIDs(ctx, scope.IDList) if err != nil { return nil, err } addUserEmailLocalPartOwners(used, users) } if len(scope.SlugList) > 0 { users, err := h.UserRepo.FindByCompanyCodes(ctx, scope.SlugList) if err != nil { return nil, err } addUserEmailLocalPartOwners(used, users) } return used, nil } func (s *hanmacEmailScope) ContainsTenant(tenantID string, slug string) bool { if s == nil { return false } if tenantID != "" && s.TenantIDs[tenantID] { return true } return s.Slugs[strings.ToLower(strings.TrimSpace(slug))] } func isTenantDescendantOf(tenant domain.Tenant, rootID string, tenantByID map[string]domain.Tenant) bool { if tenant.ID == rootID { return true } visited := make(map[string]bool) parentID := "" if tenant.ParentID != nil { parentID = *tenant.ParentID } for parentID != "" { if parentID == rootID { return true } if visited[parentID] { return false } visited[parentID] = true parent, ok := tenantByID[parentID] if !ok || parent.ParentID == nil { return false } parentID = *parent.ParentID } return false } func addUserEmailLocalPartOwners(target map[string]hanmacLocalPartOwner, users []domain.User) { for _, user := range users { localPart, err := domain.ExtractNormalizedEmailLocalPart(user.Email) if err != nil || localPart == "" { continue } if _, exists := target[localPart]; exists { continue } target[localPart] = hanmacLocalPartOwner{ UserID: strings.TrimSpace(user.ID), Email: strings.TrimSpace(user.Email), Name: strings.TrimSpace(user.Name), } } } func formatHanmacLocalPartConflictMessage(localPart string, owner hanmacLocalPartOwner) string { message := fmt.Sprintf("한맥가족 내에서 이미 사용 중인 이메일 ID입니다. local-part=%s", strings.TrimSpace(localPart)) if owner.Email != "" { message += ", 사용 계정=" + owner.Email } if owner.Name != "" { message += ", 사용자=" + owner.Name } if owner.UserID != "" { message += ", 사용자 ID=" + owner.UserID } return message } func nextAvailableHanmacLocalPart(base string, usedLocalParts map[string]hanmacLocalPartOwner) string { base = strings.ToLower(strings.TrimSpace(base)) if base == "" { return "" } if _, exists := usedLocalParts[base]; !exists { return base } stem, nextIndex := splitTrailingNumericSuffix(base) if stem == "" { stem = base } for index := nextIndex; ; index++ { candidate := fmt.Sprintf("%s%d", stem, index) if _, exists := usedLocalParts[candidate]; !exists { return candidate } } } func splitTrailingNumericSuffix(value string) (string, int) { value = strings.ToLower(strings.TrimSpace(value)) if value == "" { return "", 1 } index := len(value) for index > 0 && value[index-1] >= '0' && value[index-1] <= '9' { index-- } if index == len(value) { return value, 1 } stem := value[:index] suffix := value[index:] number, err := strconv.Atoi(suffix) if err != nil { return value, 1 } return stem, number + 1 } func appendUniqueString(values []string, value string) []string { if slices.Contains(values, value) { return values } return append(values, value) }