package service import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/repository" "context" "encoding/csv" "fmt" "io" "log/slog" "strings" "time" "github.com/google/uuid" ) type OrgChartService interface { ImportCSV(ctx context.Context, tenantID string, r io.Reader) error } type orgChartService struct { tenantRepo repository.TenantRepository userGroupRepo repository.UserGroupRepository userRepo repository.UserRepository ketoOutboxRepo repository.KetoOutboxRepository kratos KratosAdminService } func NewOrgChartService( tenantRepo repository.TenantRepository, userGroupRepo repository.UserGroupRepository, userRepo repository.UserRepository, ketoOutbox repository.KetoOutboxRepository, kratos KratosAdminService, ) OrgChartService { return &orgChartService{ tenantRepo: tenantRepo, userGroupRepo: userGroupRepo, userRepo: userRepo, ketoOutboxRepo: ketoOutbox, kratos: kratos, } } func (s *orgChartService) ImportCSV(ctx context.Context, tenantID string, r io.Reader) error { reader := csv.NewReader(r) header, err := reader.Read() if err != nil { return fmt.Errorf("failed to read CSV header: %w", err) } // Map header columns (Support both English and Korean) colMap := make(map[string]int) for i, name := range header { cleanName := strings.ToLower(strings.TrimSpace(name)) colMap[cleanName] = i } // Dynamic column detection for hierarchy hierarchyCols := []string{"그룹", "디비젼", "팀", "셀"} hierarchyIdx := make([]int, 0) for _, col := range hierarchyCols { if idx, ok := colMap[col]; ok { hierarchyIdx = append(hierarchyIdx, idx) } } // Map English keys for core fields fieldMapping := map[string][]string{ "email": {"email", "이메일"}, "name": {"name", "이름"}, "position": {"position", "직급"}, "jobtitle": {"jobtitle", "직무"}, "company": {"company", "소속"}, "is_owner": {"is_owner", "구분"}, } actualMap := make(map[string]int) for key, aliases := range fieldMapping { for _, alias := range aliases { if idx, ok := colMap[alias]; ok { actualMap[key] = idx break } } } // Path cache for hierarchy pathCache := make(map[string]string) for { record, err := reader.Read() if err == io.EOF { break } if err != nil { slog.Error("Failed to read CSV record", "error", err) continue } email := strings.TrimSpace(record[actualMap["email"]]) name := strings.TrimSpace(record[actualMap["name"]]) position := strings.TrimSpace(record[actualMap["position"]]) jobTitle := strings.TrimSpace(record[actualMap["jobtitle"]]) companyName := strings.TrimSpace(record[actualMap["company"]]) // Determine if owner (e.g. "팀장", "그룹장", "센터장", "실장") isOwner := false if idx, ok := actualMap["is_owner"]; ok { val := record[idx] isOwner = strings.HasSuffix(val, "장") || strings.EqualFold(val, "true") || val == "1" } if email == "" || name == "" { continue } // 1. Process Hierarchy (Build path from multiple columns) var parts []string for _, idx := range hierarchyIdx { val := strings.TrimSpace(record[idx]) if val != "" && val != "-" { parts = append(parts, val) } } orgPath := strings.Join(parts, "/") leafID := tenantID // Default to root if orgPath != "" { leafID, err = s.ensureOrgPath(ctx, tenantID, orgPath, pathCache) if err != nil { slog.Error("Failed to ensure hierarchy", "path", orgPath, "error", err) continue } } // 2. Ensure User exists in Kratos (Auto-create if missing) kratosID, err := s.kratos.FindIdentityIDByIdentifier(ctx, email) if err != nil || kratosID == "" { slog.Info("User not found in Kratos, auto-creating...", "email", email) // Map company name to slug (Simple mapping for now) companyCode := strings.ToLower(companyName) if companyCode == "한맥" { companyCode = "hanmac" } if companyCode == "삼안" { companyCode = "saman" } brokerUser := &domain.BrokerUser{ Email: email, Name: name, Attributes: map[string]interface{}{ "affiliationType": "AFFILIATE", "companyCode": companyCode, "department": orgPath, "grade": "member", }, } // Default password for bulk import newID, createErr := s.kratos.CreateUser(ctx, brokerUser, "baron1234!@#") if createErr != nil { slog.Error("Failed to auto-create user in Kratos", "email", email, "error", createErr) continue } kratosID = newID } // 3. Update User in Local DB companyCode := strings.ToLower(companyName) if companyCode == "한맥" { companyCode = "hanmac" } if companyCode == "삼안" { companyCode = "saman" } user := &domain.User{ ID: kratosID, Email: email, Name: name, Position: position, JobTitle: jobTitle, Department: orgPath, TenantID: &leafID, CompanyCode: companyCode, AffiliationType: "AFFILIATE", Status: "active", UpdatedAt: time.Now(), } if err := s.userRepo.Update(ctx, user); err != nil { slog.Error("Failed to update user in local DB", "email", email, "error", err) continue } // 4. Sync Membership to Keto if s.ketoOutboxRepo != nil { // Add as member of the specific unit _ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{ Namespace: "Tenant", Object: leafID, Relation: "members", Subject: "User:" + kratosID, Action: domain.KetoOutboxActionCreate, }) // If owner/leader, assign owner role if isOwner { _ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{ Namespace: "Tenant", Object: leafID, Relation: "owners", Subject: "User:" + kratosID, Action: domain.KetoOutboxActionCreate, }) } } } return nil } func (s *orgChartService) ensureOrgPath(ctx context.Context, rootTenantID string, path string, cache map[string]string) (string, error) { parts := strings.Split(path, "/") currentParentID := rootTenantID currentPath := "" for i, part := range parts { part = strings.TrimSpace(part) if part == "" { continue } if currentPath == "" { currentPath = part } else { currentPath = currentPath + "/" + part } if id, ok := cache[currentPath]; ok { currentParentID = id continue } // Check DB if already exists var existingID string if s.userGroupRepo != nil { groups, err := s.userGroupRepo.ListByTenantID(ctx, rootTenantID) if err == nil { for _, g := range groups { // Match by name and parent if g.Name == part && ((g.ParentID == nil && currentParentID == rootTenantID) || (g.ParentID != nil && *g.ParentID == currentParentID)) { existingID = g.ID break } } } } if existingID == "" { // Create new unit unitID := uuid.NewString() // 1. Create Tenant (Type: USER_GROUP) newTenant := &domain.Tenant{ ID: unitID, Type: domain.TenantTypeUserGroup, ParentID: ¤tParentID, Name: part, Slug: fmt.Sprintf("ug-%s", unitID[:8]), Status: domain.TenantStatusActive, } if err := s.tenantRepo.Create(ctx, newTenant); err != nil { return "", err } // 2. Create UserGroup metadata newUserGroup := &domain.UserGroup{ ID: unitID, TenantID: rootTenantID, ParentID: ¤tParentID, Name: part, UnitType: s.guessUnitType(i, len(parts)), } if err := s.userGroupRepo.Create(ctx, newUserGroup); err != nil { return "", err } // 3. Sync Hierarchy to Keto via Outbox if s.ketoOutboxRepo != nil { _ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{ Namespace: "Tenant", Object: unitID, Relation: "parents", Subject: "Tenant:" + currentParentID, Action: domain.KetoOutboxActionCreate, }) } existingID = unitID } cache[currentPath] = existingID currentParentID = existingID } return currentParentID, nil } func (s *orgChartService) guessUnitType(index, total int) string { if total == 1 { return "Team" } if index == 0 { return "Division" } if index == total-1 { return "Team" } return "Department" }