package service import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/repository" "context" "encoding/csv" "fmt" "io" "log/slog" "strings" "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 colMap := make(map[string]int) for i, name := range header { colMap[strings.ToLower(strings.TrimSpace(name))] = i } // Required columns required := []string{"email", "name", "organization", "position", "jobtitle"} for _, req := range required { if _, ok := colMap[req]; !ok { return fmt.Errorf("missing required column: %s", req) } } // Cache for created/found organization units to handle hierarchy efficiently // key: path (e.g. "HQ/Sales"), value: ID 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[colMap["email"]]) name := strings.TrimSpace(record[colMap["name"]]) orgPath := strings.TrimSpace(record[colMap["organization"]]) position := strings.TrimSpace(record[colMap["position"]]) jobTitle := strings.TrimSpace(record[colMap["jobtitle"]]) isOwner := false if idx, ok := colMap["is_owner"]; ok && idx < len(record) { val := strings.ToLower(record[idx]) isOwner = val == "true" || val == "y" || val == "1" || val == "yes" } if email == "" || name == "" || orgPath == "" { continue } // 1. Process Organization Hierarchy leafID, err := s.ensureOrgPath(ctx, tenantID, orgPath, pathCache) if err != nil { slog.Error("Failed to ensure org path", "path", orgPath, "error", err) continue } // 2. Upsert User // Check if user exists in Kratos first (SoT) kratosID, err := s.kratos.FindIdentityIDByIdentifier(ctx, email) if err != nil || kratosID == "" { slog.Warn("User not found in Kratos, skipping import for now. Users must be registered in Kratos first.", "email", email) continue } // Update User in Local DB (Read-Model) user, err := s.userRepo.FindByID(ctx, kratosID) if err != nil { // If not in local DB, create it user = &domain.User{ ID: kratosID, Email: email, } } user.Name = name user.Position = position user.JobTitle = jobTitle user.Department = orgPath user.TenantID = &tenantID user.Status = "active" if err := s.userRepo.Update(ctx, user); err != nil { slog.Error("Failed to update user in local DB", "userID", kratosID, "error", err) continue } // 3. Sync Membership to Keto via Outbox if s.ketoOutboxRepo != nil { // Add as member of UserGroup (which is a Tenant namespace object) _ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{ Namespace: "Tenant", Object: leafID, Relation: "members", Subject: "User:" + kratosID, Action: domain.KetoOutboxActionCreate, }) // [New] Also add as member of the root Tenant (for tenant-level member count) if leafID != tenantID { _ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{ Namespace: "Tenant", Object: tenantID, Relation: "members", Subject: "User:" + kratosID, Action: domain.KetoOutboxActionCreate, }) } // Add as owner if applicable 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" }