forked from baron/baron-sso
316 lines
8.0 KiB
Go
316 lines
8.0 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
|
|
}
|
|
|
|
// 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"
|
|
}
|