1
0
forked from baron/baron-sso
Files
baron-sso/backend/internal/service/org_chart_service.go

406 lines
11 KiB
Go

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
}
// Extract domain from email
parts := strings.Split(email, "@")
domainName := ""
if len(parts) == 2 {
domainName = parts[1]
}
// 1. Ensure Company Tenant Exists (The Core Requirement)
companySlug := s.generateCompanySlug(companyName)
companyTenantID, err := s.ensureCompanyTenant(ctx, tenantID, companyName, companySlug, domainName, pathCache)
if err != nil {
slog.Error("Failed to ensure company tenant", "company", companyName, "error", err)
continue
}
// 2. Process Hierarchy (Build path from multiple columns under the Company)
var orgParts []string
for _, idx := range hierarchyIdx {
val := strings.TrimSpace(record[idx])
if val != "" && val != "-" {
orgParts = append(orgParts, val)
}
}
orgPath := strings.Join(orgParts, "/")
leafID := companyTenantID // Default to company tenant
if orgPath != "" {
leafID, err = s.ensureOrgPath(ctx, companyTenantID, orgPath, pathCache)
if err != nil {
slog.Error("Failed to ensure hierarchy", "path", orgPath, "error", err)
continue
}
}
// 3. 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)
brokerUser := &domain.BrokerUser{
Email: email,
Name: name,
Attributes: map[string]interface{}{
"affiliationType": "AFFILIATE",
"companyCode": companySlug,
"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
}
// 4. Update User in Local DB (Bind to Company Tenant)
user := &domain.User{
ID: kratosID,
Email: email,
Name: name,
Position: position,
JobTitle: jobTitle,
Department: orgPath,
TenantID: &companyTenantID, // 편입: 사용자의 메인 소속은 회사(COMPANY)
CompanyCode: companySlug,
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
}
// 5. Sync Membership to Keto
if s.ketoOutboxRepo != nil {
// Add as member of the Company Tenant
_ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: companyTenantID,
Relation: "members",
Subject: "User:" + kratosID,
Action: domain.KetoOutboxActionCreate,
})
// Add as member of the specific Department unit (if exists)
if leafID != companyTenantID {
_ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: leafID,
Relation: "members",
Subject: "User:" + kratosID,
Action: domain.KetoOutboxActionCreate,
})
}
// If owner/leader, assign owner role to the leaf unit
if isOwner {
_ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: leafID,
Relation: "owners",
Subject: "User:" + kratosID,
Action: domain.KetoOutboxActionCreate,
})
}
}
}
return nil
}
// [New] Maps korean names to official slugs
func (s *orgChartService) generateCompanySlug(name string) string {
n := strings.ToLower(strings.TrimSpace(name))
if strings.Contains(n, "한맥") || strings.Contains(n, "hanmac") {
return "hanmac"
}
if strings.Contains(n, "삼안") || strings.Contains(n, "saman") {
return "saman"
}
if strings.Contains(n, "장헌") || strings.Contains(n, "jangheon") {
return "jangheon"
}
if strings.Contains(n, "평화") || strings.Contains(n, "ptc") {
return "ptc"
}
if strings.Contains(n, "바론") || strings.Contains(n, "baron") {
return "baron"
}
if strings.Contains(n, "한라") || strings.Contains(n, "halla") {
return "halla"
}
// Fallback for unknown companies
return "comp-" + uuid.NewString()[:8]
}
// [New] Ensures the COMPANY tenant exists and binds the domain
func (s *orgChartService) ensureCompanyTenant(ctx context.Context, rootTenantID, name, slug, domainName string, cache map[string]string) (string, error) {
cacheKey := "company:" + slug
if id, ok := cache[cacheKey]; ok {
return id, nil
}
tenant, err := s.tenantRepo.FindBySlug(ctx, slug)
if err != nil && !strings.Contains(err.Error(), "record not found") {
return "", err
}
if tenant == nil {
// Auto-create missing company tenant
slog.Info("Auto-creating missing company tenant", "name", name, "slug", slug)
tenant = &domain.Tenant{
ID: uuid.NewString(),
Name: name,
Slug: slug,
Type: domain.TenantTypeCompany,
Status: domain.TenantStatusActive,
ParentID: &rootTenantID, // Bind to the root COMPANY_GROUP
}
if err := s.tenantRepo.Create(ctx, tenant); err != nil {
return "", err
}
// Sync hierarchy to Keto (Company belongs to Group)
if s.ketoOutboxRepo != nil {
_ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: tenant.ID,
Relation: "parents",
Subject: "Tenant:" + rootTenantID,
Action: domain.KetoOutboxActionCreate,
})
}
}
// Ensure the email domain is registered to this company
if domainName != "" {
_ = s.tenantRepo.AddDomain(ctx, tenant.ID, domainName, true)
}
cache[cacheKey] = tenant.ID
return tenant.ID, 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: &currentParentID,
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: &currentParentID,
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"
}