forked from baron/baron-sso
조직도 기능 추가
This commit is contained in:
@@ -85,6 +85,7 @@ type UserProfileResponse struct {
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
Tenant *Tenant `json:"tenant,omitempty"`
|
||||
ManageableTenants []Tenant `json:"manageableTenants,omitempty"` // 추가: 관리 가능한 테넌트 목록
|
||||
JoinedTenants []Tenant `json:"joinedTenants,omitempty"` // [New] 다중 소속 테넌트 목록
|
||||
}
|
||||
|
||||
type UpdateUserRequest struct {
|
||||
|
||||
@@ -21,13 +21,18 @@ const (
|
||||
func NormalizeRole(role string) string {
|
||||
normalized := strings.ToLower(strings.TrimSpace(role))
|
||||
switch normalized {
|
||||
case "tenant_member":
|
||||
return RoleUser
|
||||
case "admin":
|
||||
// Legacy admin is treated as tenant admin for least-privilege compatibility.
|
||||
return RoleTenantAdmin
|
||||
default:
|
||||
case RoleSuperAdmin, RoleTenantAdmin, RoleUser:
|
||||
return normalized
|
||||
case "tenant_member", "member":
|
||||
return RoleUser
|
||||
case "admin", "tenantadmin", "tenant-admin":
|
||||
return RoleTenantAdmin
|
||||
case "superadmin", "super-admin":
|
||||
return RoleSuperAdmin
|
||||
default:
|
||||
// Default any other business title (팀장, 그룹장, etc.) to a regular user.
|
||||
// These should be mapped to JobTitle or Position instead.
|
||||
return RoleUser
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5213,11 +5213,18 @@ func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileRe
|
||||
}
|
||||
}
|
||||
|
||||
// [New] Fetch manageable tenants for Tenant Admin
|
||||
if profile.Role == domain.RoleTenantAdmin && h.TenantService != nil {
|
||||
manageable, err := h.TenantService.ListManageableTenants(c.Context(), profile.ID)
|
||||
// [New] Fetch manageable and joined tenants
|
||||
if h.TenantService != nil {
|
||||
if profile.Role == domain.RoleTenantAdmin {
|
||||
manageable, err := h.TenantService.ListManageableTenants(c.Context(), profile.ID)
|
||||
if err == nil {
|
||||
profile.ManageableTenants = manageable
|
||||
}
|
||||
}
|
||||
|
||||
joined, err := h.TenantService.ListJoinedTenants(c.Context(), profile.ID)
|
||||
if err == nil {
|
||||
profile.ManageableTenants = manageable
|
||||
profile.JoinedTenants = joined
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ func NewOrgChartHandler(s service.OrgChartService) *OrgChartHandler {
|
||||
return &OrgChartHandler{Service: s}
|
||||
}
|
||||
|
||||
func (h *OrgChartHandler) ImportCSV(c *fiber.Ctx) error {
|
||||
func (h *OrgChartHandler) ImportOrgChart(c *fiber.Ctx) error {
|
||||
tenantID := c.Params("tenantId")
|
||||
if tenantID == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenantId is required"})
|
||||
@@ -32,10 +32,30 @@ func (h *OrgChartHandler) ImportCSV(c *fiber.Ctx) error {
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if err := h.Service.ImportCSV(c.Context(), tenantID, f); err != nil {
|
||||
slog.Error("Failed to import CSV", "error", err, "tenantID", tenantID)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
progressID := c.Query("progressId")
|
||||
result, err := h.Service.ImportOrgChart(c.Context(), tenantID, f, file.Filename, progressID)
|
||||
if err != nil {
|
||||
slog.Error("Failed to import org chart", "error", err, "tenantID", tenantID, "filename", file.Filename)
|
||||
// If we have a result even with error, return it
|
||||
if result != nil {
|
||||
return c.Status(fiber.StatusOK).JSON(fiber.Map{
|
||||
"message": "Import completed with errors",
|
||||
"data": result,
|
||||
})
|
||||
}
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{"message": "Import completed successfully"})
|
||||
return c.JSON(fiber.Map{
|
||||
"message": "Import completed",
|
||||
"data": result,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *OrgChartHandler) GetImportProgress(c *fiber.Ctx) error {
|
||||
pid := c.Params("progressId")
|
||||
if val, ok := service.ImportProgressCache.Load(pid); ok {
|
||||
return c.JSON(val)
|
||||
}
|
||||
return c.JSON(fiber.Map{"current": 0, "total": 0})
|
||||
}
|
||||
|
||||
@@ -739,6 +739,36 @@ func (h *TenantHandler) RemoveOwner(c *fiber.Ctx) error {
|
||||
return c.SendStatus(fiber.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *TenantHandler) DeleteTenantsBulk(c *fiber.Ctx) error {
|
||||
var req struct {
|
||||
IDs []string `json:"ids"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
||||
}
|
||||
|
||||
if len(req.IDs) == 0 {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "no IDs provided")
|
||||
}
|
||||
|
||||
// Permission check: Super Admin can delete anything.
|
||||
// Tenant Admin should theoretically only delete manageable sub-tenants,
|
||||
// but currently bulk delete is intended for Super Admin.
|
||||
profile, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
||||
if profile == nil || domain.NormalizeRole(profile.Role) != domain.RoleSuperAdmin {
|
||||
return errorJSON(c, fiber.StatusForbidden, "only super admin can perform bulk deletion")
|
||||
}
|
||||
|
||||
if err := h.Service.DeleteTenantsBulk(c.Context(), req.IDs); err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusOK).JSON(fiber.Map{
|
||||
"message": "Tenants deleted successfully",
|
||||
"count": len(req.IDs),
|
||||
})
|
||||
}
|
||||
|
||||
func mapTenantSummary(t domain.Tenant) tenantSummary {
|
||||
domains := make([]string, 0, len(t.Domains))
|
||||
for _, d := range t.Domains {
|
||||
|
||||
@@ -105,11 +105,23 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
|
||||
if profile != nil {
|
||||
for _, t := range profile.ManageableTenants {
|
||||
manageableSlugs[strings.ToLower(t.Slug)] = true
|
||||
manageableSlugs[strings.ToLower(t.ID)] = true // Add ID as well
|
||||
}
|
||||
// Include primary tenant slug if not already there
|
||||
if profile.CompanyCode != "" {
|
||||
manageableSlugs[strings.ToLower(profile.CompanyCode)] = true
|
||||
}
|
||||
if profile.TenantID != nil {
|
||||
manageableSlugs[strings.ToLower(*profile.TenantID)] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var targetTenantID string
|
||||
if tenantSlug != "" && h.TenantService != nil {
|
||||
t, err := h.TenantService.GetTenantBySlug(c.Context(), tenantSlug)
|
||||
if err == nil && t != nil {
|
||||
targetTenantID = strings.ToLower(t.ID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,16 +135,17 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
|
||||
email := strings.ToLower(extractTraitString(identity.Traits, "email"))
|
||||
name := strings.ToLower(extractTraitString(identity.Traits, "name"))
|
||||
compCode := strings.ToLower(extractTraitString(identity.Traits, "companyCode"))
|
||||
tID := strings.ToLower(extractTraitString(identity.Traits, "tenant_id"))
|
||||
|
||||
// Tenant Admin filtering
|
||||
if requesterRole == domain.RoleTenantAdmin {
|
||||
if !manageableSlugs[compCode] {
|
||||
if !manageableSlugs[compCode] && !manageableSlugs[tID] {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Dedicated tenantSlug filter
|
||||
if tenantSlug != "" && !strings.EqualFold(compCode, tenantSlug) {
|
||||
if tenantSlug != "" && !strings.EqualFold(compCode, tenantSlug) && tID != targetTenantID {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -261,15 +274,17 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
LoginID string `json:"loginId"`
|
||||
Password string `json:"password"`
|
||||
Name string `json:"name"`
|
||||
Phone string `json:"phone"`
|
||||
Role string `json:"role"`
|
||||
CompanyCode string `json:"companyCode"`
|
||||
Department string `json:"department"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
Email string `json:"email"`
|
||||
LoginID string `json:"loginId"`
|
||||
Password string `json:"password"`
|
||||
Name string `json:"name"`
|
||||
Phone string `json:"phone"`
|
||||
Role string `json:"role"`
|
||||
CompanyCode string `json:"companyCode"`
|
||||
Department string `json:"department"`
|
||||
Position string `json:"position"`
|
||||
JobTitle string `json:"jobTitle"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
||||
@@ -321,6 +336,8 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
||||
|
||||
attributes := map[string]interface{}{
|
||||
"department": req.Department,
|
||||
"position": req.Position,
|
||||
"jobTitle": req.JobTitle,
|
||||
"affiliationType": "internal",
|
||||
"companyCode": req.CompanyCode,
|
||||
"grade": role,
|
||||
@@ -454,6 +471,8 @@ type bulkUserItem struct {
|
||||
Role string `json:"role"`
|
||||
TenantSlug string `json:"tenantSlug"`
|
||||
Department string `json:"department"`
|
||||
Position string `json:"position"`
|
||||
JobTitle string `json:"jobTitle"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
}
|
||||
|
||||
@@ -570,6 +589,8 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
||||
|
||||
attributes := map[string]interface{}{
|
||||
"department": dept,
|
||||
"position": strings.TrimSpace(item.Position),
|
||||
"jobTitle": strings.TrimSpace(item.JobTitle),
|
||||
"affiliationType": "internal",
|
||||
"companyCode": tenantSlug,
|
||||
"tenant_id": tItem.ID,
|
||||
@@ -845,6 +866,8 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
|
||||
Role *string `json:"role"`
|
||||
CompanyCode *string `json:"companyCode"`
|
||||
Department *string `json:"department"`
|
||||
Position *string `json:"position"`
|
||||
JobTitle *string `json:"jobTitle"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
||||
@@ -879,6 +902,16 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
|
||||
results := make([]map[string]any, 0, len(req.UserIDs))
|
||||
|
||||
for _, id := range req.UserIDs {
|
||||
// [Safety] Cannot delete yourself
|
||||
if id == requester.ID {
|
||||
results = append(results, map[string]any{
|
||||
"id": id,
|
||||
"success": false,
|
||||
"message": "cannot delete your own account for safety",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
identity, err := h.KratosAdmin.GetIdentity(c.Context(), id)
|
||||
if err != nil {
|
||||
results = append(results, map[string]any{"id": id, "success": false, "message": "user not found"})
|
||||
@@ -924,6 +957,12 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
|
||||
if req.Department != nil {
|
||||
traits["department"] = *req.Department
|
||||
}
|
||||
if req.Position != nil {
|
||||
traits["position"] = *req.Position
|
||||
}
|
||||
if req.JobTitle != nil {
|
||||
traits["jobTitle"] = *req.JobTitle
|
||||
}
|
||||
|
||||
state := identity.State
|
||||
if req.Status != nil {
|
||||
@@ -958,6 +997,12 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
|
||||
if req.Department != nil {
|
||||
localUser.Department = *req.Department
|
||||
}
|
||||
if req.Position != nil {
|
||||
localUser.Position = *req.Position
|
||||
}
|
||||
if req.JobTitle != nil {
|
||||
localUser.JobTitle = *req.JobTitle
|
||||
}
|
||||
|
||||
// Resolve TenantID if changing companyCode
|
||||
if req.CompanyCode != nil && h.TenantService != nil {
|
||||
@@ -1011,6 +1056,16 @@ func (h *UserHandler) BulkDeleteUsers(c *fiber.Ctx) error {
|
||||
results := make([]map[string]any, 0, len(req.UserIDs))
|
||||
|
||||
for _, id := range req.UserIDs {
|
||||
// [Safety] Cannot delete yourself
|
||||
if id == requester.ID {
|
||||
results = append(results, map[string]any{
|
||||
"id": id,
|
||||
"success": false,
|
||||
"message": "cannot delete your own account for safety",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
identity, err := h.KratosAdmin.GetIdentity(c.Context(), id)
|
||||
if err != nil {
|
||||
results = append(results, map[string]any{"id": id, "success": false, "message": "user not found"})
|
||||
@@ -1093,6 +1148,8 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
Status *string `json:"status"`
|
||||
CompanyCode *string `json:"companyCode"`
|
||||
Department *string `json:"department"`
|
||||
Position *string `json:"position"`
|
||||
JobTitle *string `json:"jobTitle"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
@@ -1176,6 +1233,12 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
if req.Department != nil {
|
||||
traits["department"] = strings.TrimSpace(*req.Department)
|
||||
}
|
||||
if req.Position != nil {
|
||||
traits["position"] = strings.TrimSpace(*req.Position)
|
||||
}
|
||||
if req.JobTitle != nil {
|
||||
traits["jobTitle"] = strings.TrimSpace(*req.JobTitle)
|
||||
}
|
||||
if req.Role != nil {
|
||||
role := domain.NormalizeRole(*req.Role)
|
||||
if role == "" {
|
||||
@@ -1189,6 +1252,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
coreTraits := map[string]bool{
|
||||
"email": true, "name": true, "phone_number": true,
|
||||
"grade": true, "companyCode": true, "department": true,
|
||||
"position": true, "jobTitle": true,
|
||||
"affiliationType": true, "role": true, "tenant_id": true,
|
||||
"custom_login_ids": true, "id": true,
|
||||
}
|
||||
@@ -1339,6 +1403,12 @@ func (h *UserHandler) DeleteUser(c *fiber.Ctx) error {
|
||||
|
||||
// [New] Check access scope before deletion
|
||||
requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
||||
|
||||
// [Safety] Cannot delete yourself
|
||||
if requester != nil && userID == requester.ID {
|
||||
return errorJSON(c, fiber.StatusForbidden, "cannot delete your own account for safety")
|
||||
}
|
||||
|
||||
if requester != nil && domain.NormalizeRole(requester.Role) == domain.RoleTenantAdmin {
|
||||
identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID)
|
||||
if err == nil && identity != nil {
|
||||
@@ -1407,14 +1477,16 @@ func (h *UserHandler) mapIdentitySummary(ctx context.Context, identity service.K
|
||||
Status: normalizeStatus(identity.State),
|
||||
CompanyCode: compCode,
|
||||
Department: extractTraitString(traits, "department"),
|
||||
Position: extractTraitString(traits, "position"),
|
||||
JobTitle: extractTraitString(traits, "jobTitle"),
|
||||
Metadata: make(domain.JSONMap),
|
||||
CreatedAt: formatTime(identity.CreatedAt),
|
||||
UpdatedAt: formatTime(identity.UpdatedAt),
|
||||
}
|
||||
|
||||
// [New] Fetch all manageable tenants (for Multi-tenancy support)
|
||||
// [New] Fetch all joined tenants (for Multi-tenancy support)
|
||||
if h.TenantService != nil {
|
||||
if joined, err := h.TenantService.ListManageableTenants(ctx, identity.ID); err == nil {
|
||||
if joined, err := h.TenantService.ListJoinedTenants(ctx, identity.ID); err == nil {
|
||||
summary.JoinedTenants = joined
|
||||
}
|
||||
}
|
||||
@@ -1426,6 +1498,7 @@ func (h *UserHandler) mapIdentitySummary(ctx context.Context, identity service.K
|
||||
coreTraits := map[string]bool{
|
||||
"email": true, "name": true, "phone_number": true,
|
||||
"grade": true, "companyCode": true, "department": true,
|
||||
"position": true, "jobTitle": true,
|
||||
"affiliationType": true, "role": true, "tenant_id": true,
|
||||
"custom_login_ids": true, "id": true,
|
||||
}
|
||||
@@ -1476,6 +1549,8 @@ func (h *UserHandler) mapToLocalUser(identity service.KratosIdentity) *domain.Us
|
||||
Status: normalizeStatus(identity.State),
|
||||
CompanyCode: compCode,
|
||||
Department: extractTraitString(traits, "department"),
|
||||
Position: extractTraitString(traits, "position"),
|
||||
JobTitle: extractTraitString(traits, "jobTitle"),
|
||||
AffiliationType: extractTraitString(traits, "affiliationType"),
|
||||
CreatedAt: identity.CreatedAt,
|
||||
UpdatedAt: identity.UpdatedAt,
|
||||
@@ -1501,6 +1576,7 @@ func (h *UserHandler) mapToLocalUser(identity service.KratosIdentity) *domain.Us
|
||||
coreTraits := map[string]bool{
|
||||
"email": true, "name": true, "phone_number": true,
|
||||
"grade": true, "companyCode": true, "department": true,
|
||||
"position": true, "jobTitle": true,
|
||||
"affiliationType": true, "role": true, "tenant_id": true, "company_code": true,
|
||||
"custom_login_ids": true, "id": true,
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -19,6 +20,7 @@ type TenantRepository interface {
|
||||
AddDomain(ctx context.Context, tenantID string, domainName string, verified bool) error
|
||||
List(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error)
|
||||
ListByType(ctx context.Context, tenantType string) ([]domain.Tenant, error)
|
||||
DeleteBulk(ctx context.Context, ids []string) error
|
||||
}
|
||||
|
||||
type tenantRepository struct {
|
||||
@@ -121,3 +123,30 @@ func (r *tenantRepository) ListByType(ctx context.Context, tenantType string) ([
|
||||
}
|
||||
return tenants, nil
|
||||
}
|
||||
|
||||
func (r *tenantRepository) DeleteBulk(ctx context.Context, ids []string) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// 1. Release slugs for all target tenants to allow reuse
|
||||
suffix := "-deleted-" + time.Now().Format("20060102150405")
|
||||
if err := tx.Model(&domain.Tenant{}).Where("id IN ?", ids).
|
||||
Update("slug", gorm.Expr("slug || ?", suffix)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 2. Soft delete tenants
|
||||
if err := tx.Where("id IN ?", ids).Delete(&domain.Tenant{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 3. Also delete related UserGroups if any (Type USER_GROUP tenants have records in user_groups table)
|
||||
if err := tx.Where("id IN ?", ids).Delete(&domain.UserGroup{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
@@ -42,11 +42,30 @@ func (r *userRepository) Create(ctx context.Context, user *domain.User) error {
|
||||
}
|
||||
|
||||
func (r *userRepository) Update(ctx context.Context, user *domain.User) error {
|
||||
// Use Upsert logic: if email exists, update all fields
|
||||
return r.db.WithContext(ctx).Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "email"}},
|
||||
UpdateAll: true,
|
||||
}).Save(user).Error
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// 1. Resolve email conflicts: If another user in the local DB has this email but a different ID,
|
||||
// we must remove the old local record because Kratos is the source of truth for ID <-> Email mapping.
|
||||
var existing domain.User
|
||||
if err := tx.Unscoped().Where("email = ?", user.Email).First(&existing).Error; err == nil {
|
||||
if existing.ID != user.ID {
|
||||
// Delete associated login IDs first to prevent FK constraint violation
|
||||
if err := tx.Unscoped().Where("user_id = ?", existing.ID).Delete(&domain.UserLoginID{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// Different ID holds this email locally. Hard delete the old record to avoid constraint violation.
|
||||
if err := tx.Unscoped().Delete(&domain.User{}, "id = ?", existing.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Perform Upsert based on ID.
|
||||
// In GORM v2, true upsert requires Create() with OnConflict on the primary key.
|
||||
return tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "id"}},
|
||||
UpdateAll: true,
|
||||
}).Create(user).Error
|
||||
})
|
||||
}
|
||||
|
||||
func (r *userRepository) FindByEmail(ctx context.Context, email string) (*domain.User, error) {
|
||||
@@ -175,13 +194,14 @@ func (r *userRepository) List(ctx context.Context, offset, limit int, search str
|
||||
db := r.db.WithContext(ctx).Model(&domain.User{})
|
||||
|
||||
if companyCode != "" {
|
||||
db = db.Where("company_code = ?", companyCode)
|
||||
// [Matrix Fix] Match users either by their primary company code OR by the slug of the department they are attached to
|
||||
db = db.Joins("LEFT JOIN tenants ON users.tenant_id = tenants.id").
|
||||
Where("users.company_code = ? OR tenants.slug = ?", companyCode, companyCode)
|
||||
}
|
||||
|
||||
if search != "" {
|
||||
searchTerm := "%" + search + "%"
|
||||
// Search in basic fields and metadata (PostgreSQL JSONB)
|
||||
db = db.Where("(email LIKE ? OR name LIKE ? OR company_code LIKE ? OR metadata::text LIKE ?)",
|
||||
db = db.Where("(users.email LIKE ? OR users.name LIKE ? OR users.company_code LIKE ? OR users.metadata::text LIKE ?)",
|
||||
searchTerm, searchTerm, searchTerm, searchTerm)
|
||||
}
|
||||
|
||||
@@ -189,7 +209,7 @@ func (r *userRepository) List(ctx context.Context, offset, limit int, search str
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
if err := db.Offset(offset).Limit(limit).Find(&users).Error; err != nil {
|
||||
if err := db.Offset(offset).Limit(limit).Preload("Tenant").Find(&users).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
|
||||
@@ -94,7 +94,6 @@ func (m *MockKratosAdminServiceShared) GetIdentity(ctx context.Context, identity
|
||||
}
|
||||
return args.Get(0).(*KratosIdentity), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockKratosAdminServiceShared) UpdateIdentity(ctx context.Context, identityID string, traits map[string]interface{}, state string) (*KratosIdentity, error) {
|
||||
args := m.Called(ctx, identityID, traits, state)
|
||||
if args.Get(0) == nil {
|
||||
@@ -104,9 +103,18 @@ func (m *MockKratosAdminServiceShared) UpdateIdentity(ctx context.Context, ident
|
||||
}
|
||||
|
||||
func (m *MockKratosAdminServiceShared) UpdateIdentityPassword(ctx context.Context, identityID, newPassword string) error {
|
||||
return m.Called(ctx, identityID, newPassword).Error(0)
|
||||
args := m.Called(ctx, identityID, newPassword)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockKratosAdminServiceShared) DeleteIdentity(ctx context.Context, identityID string) error {
|
||||
return m.Called(ctx, identityID).Error(0)
|
||||
args := m.Called(ctx, identityID)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockKratosAdminServiceShared) CreateUser(ctx context.Context, user *domain.BrokerUser, password string) (string, error) {
|
||||
args := m.Called(ctx, user, password)
|
||||
return args.String(0), args.Error(1)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -3,19 +3,43 @@ package service
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/repository"
|
||||
"baron-sso-backend/internal/utils"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
var whitespaceRegex = regexp.MustCompile(`\s+`)
|
||||
var nonAlphaNumRegex = regexp.MustCompile(`[^a-zA-Z0-9가-힣]+`)
|
||||
|
||||
type ProgressData struct {
|
||||
Current int `json:"current"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
var ImportProgressCache sync.Map
|
||||
|
||||
type ImportResult struct {
|
||||
TotalRows int `json:"totalRows"`
|
||||
Processed int `json:"processed"`
|
||||
UserCreated int `json:"userCreated"`
|
||||
UserUpdated int `json:"userUpdated"`
|
||||
TenantCreated int `json:"tenantCreated"`
|
||||
Errors []string `json:"errors"`
|
||||
}
|
||||
|
||||
type OrgChartService interface {
|
||||
ImportCSV(ctx context.Context, tenantID string, r io.Reader) error
|
||||
ImportOrgChart(ctx context.Context, tenantID string, r io.Reader, filename string, progressID string) (*ImportResult, error)
|
||||
}
|
||||
|
||||
type orgChartService struct {
|
||||
@@ -42,364 +66,443 @@ func NewOrgChartService(
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
func (s *orgChartService) ImportOrgChart(ctx context.Context, tenantID string, r io.Reader, filename string, progressID string) (*ImportResult, error) {
|
||||
result := &ImportResult{Errors: make([]string, 0)}
|
||||
var allSheetsRecords [][][]string
|
||||
var err error
|
||||
|
||||
// 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)
|
||||
if strings.HasSuffix(strings.ToLower(filename), ".xlsx") {
|
||||
allSheetsRecords, err = s.readAllXLSXSheets(r)
|
||||
} else {
|
||||
csvRecords, csvErr := s.readCSV(r)
|
||||
if csvErr == nil {
|
||||
allSheetsRecords = [][][]string{csvRecords}
|
||||
}
|
||||
err = csvErr
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Map English keys for core fields
|
||||
fieldMapping := map[string][]string{
|
||||
"email": {"email", "이메일"},
|
||||
"name": {"name", "이름"},
|
||||
"position": {"position", "직급"},
|
||||
"jobtitle": {"jobtitle", "직무"},
|
||||
"company": {"company", "소속"},
|
||||
"is_owner": {"is_owner", "구분"},
|
||||
"email": {"email", "이메일", "e-mail", "loginid", "login_id", "id", "계정", "사용자id", "사용자계정", "사번", "employeeid", "empno", "mail", "이메일주소"},
|
||||
"name": {"name", "이름", "성함", "성명", "username", "사용자명", "성함/이름", "사용자", "사원명"},
|
||||
"position": {"position", "직급", "직위", "직급/직위", "직급명", "직위명", "rank"},
|
||||
"jobtitle": {"jobtitle", "직무", "담당업무", "업무", "담당", "수행직무", "직종"},
|
||||
"phone": {"phone", "전화번호", "연락처", "휴대폰", "휴대폰번호", "핸드폰", "tel", "mobile"},
|
||||
"company": {"company", "소속", "법인", "회사", "소속회사", "소속법인", "사업부", "co"},
|
||||
"is_owner": {"is_owner", "구분", "직책", "권한", "role", "직책명", "장급여부", "리더", "leader", "manager", "pos"},
|
||||
}
|
||||
|
||||
var dataRows [][]string
|
||||
actualMap := make(map[string]int)
|
||||
for key, aliases := range fieldMapping {
|
||||
for _, alias := range aliases {
|
||||
if idx, ok := colMap[alias]; ok {
|
||||
actualMap[key] = idx
|
||||
found := false
|
||||
var headerMap map[string]int
|
||||
|
||||
for sheetIdx, records := range allSheetsRecords {
|
||||
for i, row := range records {
|
||||
if len(row) < 2 { continue }
|
||||
tempMap := make(map[string]int)
|
||||
for j, cell := range row {
|
||||
clean := s.cleanHeader(cell)
|
||||
if clean != "" { tempMap[clean] = j }
|
||||
}
|
||||
emailIdx := s.findBestMatch(tempMap, fieldMapping["email"])
|
||||
nameIdx := s.findBestMatch(tempMap, fieldMapping["name"])
|
||||
if nameIdx != -1 && emailIdx == -1 {
|
||||
for j, cell := range row {
|
||||
c := s.cleanHeader(cell)
|
||||
if strings.Contains(c, "mail") || strings.Contains(c, "계정") || strings.Contains(c, "id") || strings.Contains(c, "사번") {
|
||||
emailIdx = j; break
|
||||
}
|
||||
}
|
||||
}
|
||||
if emailIdx != -1 && nameIdx != -1 {
|
||||
dataRows = records[i+1:]
|
||||
headerMap = tempMap
|
||||
for key, aliases := range fieldMapping {
|
||||
actualMap[key] = s.findBestMatch(tempMap, aliases)
|
||||
}
|
||||
if actualMap["email"] == -1 { actualMap["email"] = emailIdx }
|
||||
found = true
|
||||
slog.Info("Found header row", "sheet", sheetIdx, "row", i)
|
||||
break
|
||||
}
|
||||
}
|
||||
if found { break }
|
||||
}
|
||||
|
||||
if !found {
|
||||
return nil, fmt.Errorf("required columns (email/name) not found. please check your headers")
|
||||
}
|
||||
|
||||
// [MH-OrgChart-Standalone Architecture]
|
||||
// Hierarchy is explicitly ordered: 부서(part) -> 그룹(gr) -> 디비전(div) -> 팀(team) -> 셀(cell)
|
||||
hierarchyLevels := [][]string{
|
||||
{"department", "organization", "부서", "조직", "부서명", "조직명", "소속부서", "part", "파트", "본부", "실", "국"},
|
||||
{"gr", "grp", "group", "그룹"},
|
||||
{"div", "division", "디비젼", "디비전"},
|
||||
{"team", "팀", "teal", "팀명"},
|
||||
{"cell", "셀"},
|
||||
}
|
||||
|
||||
hierarchyIdx := make([]int, 0)
|
||||
for _, aliases := range hierarchyLevels {
|
||||
idx := s.findBestMatch(headerMap, aliases)
|
||||
hierarchyIdx = append(hierarchyIdx, idx) // Keep order, -1 means not found
|
||||
}
|
||||
|
||||
// Path cache for hierarchy
|
||||
pathCache := make(map[string]string)
|
||||
result.TotalRows = len(dataRows)
|
||||
|
||||
for {
|
||||
record, err := reader.Read()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("Failed to read CSV record", "error", err)
|
||||
continue
|
||||
}
|
||||
if progressID != "" {
|
||||
ImportProgressCache.Store(progressID, ProgressData{Current: 0, Total: result.TotalRows})
|
||||
defer ImportProgressCache.Delete(progressID)
|
||||
}
|
||||
|
||||
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 tenantID == "root" || tenantID == "" {
|
||||
t, _ := s.tenantRepo.FindBySlug(ctx, "root-group")
|
||||
if t == nil {
|
||||
tenantID = uuid.NewString()
|
||||
_ = s.tenantRepo.Create(ctx, &domain.Tenant{ID: tenantID, Name: "Root Group", Slug: "root-group", Type: domain.TenantTypeCompanyGroup, Status: domain.TenantStatusActive})
|
||||
result.TenantCreated++
|
||||
} else {
|
||||
tenantID = t.ID
|
||||
}
|
||||
}
|
||||
|
||||
if email == "" || name == "" {
|
||||
continue
|
||||
}
|
||||
for rowIdx, record := range dataRows {
|
||||
if len(record) == 0 { continue }
|
||||
email := s.getVal(record, actualMap["email"])
|
||||
name := s.getVal(record, actualMap["name"])
|
||||
if email == "" || name == "" { continue }
|
||||
|
||||
// Extract domain from email
|
||||
parts := strings.Split(email, "@")
|
||||
domainName := ""
|
||||
if len(parts) == 2 {
|
||||
domainName = parts[1]
|
||||
}
|
||||
position := s.getVal(record, actualMap["position"])
|
||||
jobTitle := s.getVal(record, actualMap["jobtitle"])
|
||||
phone := s.normalizePhone(s.getVal(record, actualMap["phone"]))
|
||||
|
||||
// 1. Ensure Company Tenant Exists (The Core Requirement)
|
||||
companyName := s.getVal(record, actualMap["company"])
|
||||
if companyName == "" { companyName = "Main" }
|
||||
companySlug := s.generateCompanySlug(companyName)
|
||||
companyTenantID, err := s.ensureCompanyTenant(ctx, tenantID, companyName, companySlug, domainName, pathCache)
|
||||
|
||||
companyTenantID, err := s.ensureCompanyTenant(ctx, tenantID, companyName, companySlug, email, pathCache, result)
|
||||
if err != nil {
|
||||
slog.Error("Failed to ensure company tenant", "company", companyName, "error", err)
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("Row %d: Company fail: %v", rowIdx+2, err))
|
||||
continue
|
||||
}
|
||||
|
||||
// 2. Process Hierarchy (Build path from multiple columns under the Company)
|
||||
// Build orgPath following the strict order: 부서 -> 그룹 -> 디비전 -> 팀 -> 셀
|
||||
var orgParts []string
|
||||
for _, idx := range hierarchyIdx {
|
||||
val := strings.TrimSpace(record[idx])
|
||||
if val != "" && val != "-" {
|
||||
orgParts = append(orgParts, val)
|
||||
val := s.getVal(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)
|
||||
leafID := companyTenantID
|
||||
if orgPath != "" && orgPath != "-" {
|
||||
// [Matrix Fix] Build hierarchy under the Root Tenant (tenantID), NOT the individual company.
|
||||
// This allows departments like '총괄기획실' to be shared across multiple companies without duplication.
|
||||
leafID, err = s.ensureOrgPath(ctx, tenantID, orgPath, pathCache, result)
|
||||
if err != nil {
|
||||
slog.Error("Failed to ensure hierarchy", "path", orgPath, "error", err)
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("Row %d: Hierarchy fail: %v", rowIdx+2, err))
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Ensure User exists in Kratos (Auto-create if missing)
|
||||
isOwner := false
|
||||
grade := "member"
|
||||
if idx := actualMap["is_owner"]; idx != -1 && idx < len(record) {
|
||||
grade = strings.TrimSpace(record[idx])
|
||||
isOwner = strings.HasSuffix(grade, "장") || strings.EqualFold(grade, "true") || grade == "1" ||
|
||||
strings.Contains(grade, "Manager") || strings.Contains(grade, "Leader") ||
|
||||
strings.Contains(grade, "팀장") || strings.Contains(grade, "본부장")
|
||||
}
|
||||
|
||||
kratosID, err := s.kratos.FindIdentityIDByIdentifier(ctx, email)
|
||||
if err != nil || kratosID == "" {
|
||||
slog.Info("User not found in Kratos, auto-creating...", "email", email)
|
||||
|
||||
if (err != nil || kratosID == "") && phone != "" {
|
||||
kratosID, _ = s.kratos.FindIdentityIDByIdentifier(ctx, phone)
|
||||
}
|
||||
|
||||
if kratosID == "" {
|
||||
brokerUser := &domain.BrokerUser{
|
||||
Email: email,
|
||||
Name: name,
|
||||
Email: email, Name: name, PhoneNumber: phone,
|
||||
Attributes: map[string]interface{}{
|
||||
"affiliationType": "AFFILIATE",
|
||||
"companyCode": companySlug,
|
||||
"department": orgPath,
|
||||
"grade": "member",
|
||||
"affiliationType": "AFFILIATE", "companyCode": companySlug,
|
||||
"department": orgPath, "grade": grade, "position": position,
|
||||
"tenant_id": leafID, // [Matrix Fix] Sync leafID to Kratos to prevent lazy sync overwrite
|
||||
},
|
||||
}
|
||||
// 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)
|
||||
kratosID, err = s.kratos.CreateUser(ctx, brokerUser, "baron1234!@#")
|
||||
if err != nil {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("Row %d: User creation failed: %v", rowIdx+2, err))
|
||||
continue
|
||||
}
|
||||
kratosID = newID
|
||||
result.UserCreated++
|
||||
} else {
|
||||
traits := map[string]interface{}{
|
||||
"name": name, "companyCode": companySlug, "department": orgPath,
|
||||
"grade": grade, "position": position, "affiliationType": "AFFILIATE",
|
||||
"tenant_id": leafID, // [Matrix Fix] Sync leafID to Kratos to prevent lazy sync overwrite
|
||||
}
|
||||
if phone != "" {
|
||||
traits["phone_number"] = phone
|
||||
}
|
||||
_, _ = s.kratos.UpdateIdentity(ctx, kratosID, traits, "active")
|
||||
result.UserUpdated++
|
||||
}
|
||||
|
||||
// 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(),
|
||||
err = s.userRepo.Update(ctx, &domain.User{
|
||||
ID: kratosID, Email: email, Name: name, Phone: phone, Position: position,
|
||||
JobTitle: jobTitle, Department: orgPath,
|
||||
TenantID: &leafID, // [Matrix Fix] Local DB points to Leaf Department, while CompanyCode points to the Legal Entity
|
||||
CompanyCode: companySlug, AffiliationType: "AFFILIATE", Status: "active", UpdatedAt: time.Now(), Role: domain.RoleUser,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("Row update failed", "row", rowIdx+2, "email", email, "error", err)
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("Row %d: DB Update fail: %v", rowIdx+2, err))
|
||||
}
|
||||
|
||||
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
|
||||
// 1. [Redundant Assignment] Always assign to the Legal Company Tenant
|
||||
_ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: companyTenantID,
|
||||
Relation: "members",
|
||||
Subject: "User:" + kratosID,
|
||||
Namespace: "Tenant",
|
||||
Object: companyTenantID,
|
||||
Relation: "members",
|
||||
Subject: "User:" + kratosID,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
|
||||
// Add as member of the specific Department unit (if exists)
|
||||
// 2. [Redundant Assignment] ALSO assign to the Logical Department Tenant (if exists)
|
||||
if leafID != companyTenantID {
|
||||
_ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: leafID,
|
||||
Relation: "members",
|
||||
Subject: "User:" + kratosID,
|
||||
Namespace: "Tenant",
|
||||
Object: leafID,
|
||||
Relation: "members",
|
||||
Subject: "User:" + kratosID,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
}
|
||||
|
||||
// If owner/leader, assign owner role to the leaf unit
|
||||
|
||||
// 3. Assign ownership if leader
|
||||
if isOwner {
|
||||
_ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: leafID,
|
||||
Relation: "owners",
|
||||
Subject: "User:" + kratosID,
|
||||
Namespace: "Tenant",
|
||||
Object: leafID,
|
||||
Relation: "owners",
|
||||
Subject: "User:" + kratosID,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
}
|
||||
}
|
||||
result.Processed++
|
||||
if progressID != "" && (result.Processed%5 == 0 || result.Processed == result.TotalRows) {
|
||||
ImportProgressCache.Store(progressID, ProgressData{Current: result.Processed, Total: result.TotalRows})
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *orgChartService) cleanHeader(val string) string {
|
||||
clean := strings.ToLower(whitespaceRegex.ReplaceAllString(val, ""))
|
||||
clean = nonAlphaNumRegex.ReplaceAllString(clean, "")
|
||||
return strings.TrimPrefix(clean, "\ufeff")
|
||||
}
|
||||
|
||||
func (s *orgChartService) findBestMatch(tempMap map[string]int, aliases []string) int {
|
||||
for _, alias := range aliases {
|
||||
ca := s.cleanHeader(alias)
|
||||
if idx, ok := tempMap[ca]; ok { return idx }
|
||||
}
|
||||
for cleaned, idx := range tempMap {
|
||||
for _, alias := range aliases {
|
||||
ca := s.cleanHeader(alias)
|
||||
if len(ca) >= 2 && (strings.Contains(cleaned, ca) || strings.Contains(ca, cleaned)) { return idx }
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func (s *orgChartService) getVal(record []string, idx int) string {
|
||||
if idx == -1 || idx >= len(record) { return "" }
|
||||
return strings.TrimSpace(record[idx])
|
||||
}
|
||||
|
||||
func (s *orgChartService) normalizePhone(phone string) string {
|
||||
normalized := strings.ReplaceAll(phone, "-", "")
|
||||
normalized = strings.ReplaceAll(normalized, " ", "")
|
||||
|
||||
re := regexp.MustCompile(`[^0-9+]`)
|
||||
normalized = re.ReplaceAllString(normalized, "")
|
||||
|
||||
if len(normalized) < 8 {
|
||||
return ""
|
||||
}
|
||||
|
||||
if strings.HasPrefix(normalized, "010") {
|
||||
return "+82" + normalized[1:]
|
||||
}
|
||||
if strings.HasPrefix(normalized, "82") {
|
||||
return "+" + normalized
|
||||
}
|
||||
if !strings.HasPrefix(normalized, "+") && len(normalized) >= 9 {
|
||||
if strings.HasPrefix(normalized, "0") {
|
||||
return "+82" + normalized[1:]
|
||||
}
|
||||
return "+82" + normalized
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
func (s *orgChartService) readCSV(r io.Reader) ([][]string, error) {
|
||||
data, err := io.ReadAll(r)
|
||||
if err != nil { return nil, err }
|
||||
reader := csv.NewReader(bytes.NewReader(bytes.TrimPrefix(data, []byte("\xef\xbb\xbf"))))
|
||||
reader.LazyQuotes = true
|
||||
reader.FieldsPerRecord = -1
|
||||
return reader.ReadAll()
|
||||
}
|
||||
|
||||
func (s *orgChartService) readAllXLSXSheets(r io.Reader) ([][][]string, error) {
|
||||
f, err := excelize.OpenReader(r)
|
||||
if err != nil { return nil, err }
|
||||
defer f.Close()
|
||||
var allRecords [][][]string
|
||||
for _, sheet := range f.GetSheetList() {
|
||||
if rows, err := f.GetRows(sheet); err == nil { allRecords = append(allRecords, rows) }
|
||||
}
|
||||
return allRecords, 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"
|
||||
n := strings.ToLower(whitespaceRegex.ReplaceAllString(name, ""))
|
||||
slugs := map[string]string{
|
||||
"한맥": "hanmac", "삼안": "saman", "장헌": "jangheon",
|
||||
"ptc": "ptc", "피티씨": "ptc", "바론": "baron", "한라": "halla",
|
||||
}
|
||||
if strings.Contains(n, "삼안") || strings.Contains(n, "saman") {
|
||||
return "saman"
|
||||
for k, v := range slugs {
|
||||
if strings.Contains(n, k) || strings.Contains(n, v) { return v }
|
||||
}
|
||||
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]
|
||||
return utils.GenerateSlug(name)
|
||||
}
|
||||
|
||||
// [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
|
||||
func isAlphaNumeric(s string) bool {
|
||||
for _, r := range s {
|
||||
if (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '-' { return false }
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *orgChartService) ensureCompanyTenant(ctx context.Context, rootID, name, slug, email string, cache map[string]string, res *ImportResult) (string, error) {
|
||||
if rootID == "root" || rootID == "" {
|
||||
// Auto-provision a root group if none is provided
|
||||
rootSlug := "root-group"
|
||||
t, _ := s.tenantRepo.FindBySlug(ctx, rootSlug)
|
||||
if t == nil {
|
||||
t = &domain.Tenant{ID: uuid.NewString(), Name: "Root Group", Slug: rootSlug, Type: domain.TenantTypeCompanyGroup, Status: domain.TenantStatusActive}
|
||||
_ = s.tenantRepo.Create(ctx, t)
|
||||
res.TenantCreated++
|
||||
}
|
||||
rootID = t.ID
|
||||
}
|
||||
|
||||
tenant, err := s.tenantRepo.FindBySlug(ctx, slug)
|
||||
if err != nil && !strings.Contains(err.Error(), "record not found") {
|
||||
return "", err
|
||||
cacheKey := "company:" + slug
|
||||
if id, ok := cache[cacheKey]; ok { return id, nil }
|
||||
|
||||
tenant, _ := s.tenantRepo.FindBySlug(ctx, slug)
|
||||
if tenant == nil {
|
||||
tenant, _ = s.tenantRepo.FindByName(ctx, name)
|
||||
}
|
||||
|
||||
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)
|
||||
tenant = &domain.Tenant{ID: uuid.NewString(), Name: name, Slug: slug, Type: domain.TenantTypeCompany, Status: domain.TenantStatusActive, ParentID: &rootID}
|
||||
if err := s.tenantRepo.Create(ctx, tenant); err != nil { return "", err }
|
||||
if s.ketoOutboxRepo != nil {
|
||||
_ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: tenant.ID,
|
||||
Relation: "parents",
|
||||
Subject: "Tenant:" + rootTenantID,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
_ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{Namespace: "Tenant", Object: tenant.ID, Relation: "parents", Subject: "Tenant:" + rootID, Action: domain.KetoOutboxActionCreate})
|
||||
}
|
||||
res.TenantCreated++
|
||||
}
|
||||
|
||||
// Ensure the email domain is registered to this company
|
||||
if domainName != "" {
|
||||
_ = s.tenantRepo.AddDomain(ctx, tenant.ID, domainName, true)
|
||||
}
|
||||
|
||||
|
||||
domainPart := ""
|
||||
if parts := strings.Split(email, "@"); len(parts) == 2 { domainPart = parts[1] }
|
||||
if domainPart != "" { _ = s.tenantRepo.AddDomain(ctx, tenant.ID, domainPart, 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) {
|
||||
func (s *orgChartService) ensureOrgPath(ctx context.Context, rootTenantID, path string, cache map[string]string, res *ImportResult) (string, error) {
|
||||
parts := strings.Split(path, "/")
|
||||
currentParentID := rootTenantID
|
||||
currentPath := ""
|
||||
|
||||
for i, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
if part == "" || part == "-" { continue }
|
||||
if currentPath == "" { currentPath = part } else { currentPath += "/" + part }
|
||||
|
||||
cacheKey := rootTenantID + ":" + currentPath
|
||||
if id, ok := cache[cacheKey]; ok {
|
||||
currentParentID = id; 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 groups, err := s.userGroupRepo.ListByTenantID(ctx, rootTenantID); err == nil {
|
||||
for _, g := range groups {
|
||||
isTopMatch := (g.ParentID == nil && currentParentID == rootTenantID)
|
||||
isSubMatch := (g.ParentID != nil && *g.ParentID == currentParentID)
|
||||
if g.Name == part && (isTopMatch || isSubMatch) {
|
||||
existingID = g.ID; break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if existingID == "" {
|
||||
// Create new unit
|
||||
unitID := uuid.NewString()
|
||||
existingID = uuid.NewString()
|
||||
groupSlug := fmt.Sprintf("ug-%s", existingID[:13])
|
||||
|
||||
// 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]),
|
||||
if err := s.tenantRepo.Create(ctx, &domain.Tenant{
|
||||
ID: existingID,
|
||||
Type: domain.TenantTypeUserGroup,
|
||||
ParentID: ¤tParentID,
|
||||
Name: part,
|
||||
Slug: groupSlug,
|
||||
Status: domain.TenantStatusActive,
|
||||
}
|
||||
if err := s.tenantRepo.Create(ctx, newTenant); err != nil {
|
||||
}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var ugParentID *string
|
||||
if currentParentID != rootTenantID {
|
||||
pid := currentParentID
|
||||
ugParentID = &pid
|
||||
}
|
||||
|
||||
// 2. Create UserGroup metadata
|
||||
newUserGroup := &domain.UserGroup{
|
||||
ID: unitID,
|
||||
TenantID: rootTenantID,
|
||||
ParentID: ¤tParentID,
|
||||
Name: part,
|
||||
if err := s.userGroupRepo.Create(ctx, &domain.UserGroup{
|
||||
ID: existingID,
|
||||
TenantID: rootTenantID,
|
||||
ParentID: ugParentID,
|
||||
Name: part,
|
||||
UnitType: s.guessUnitType(i, len(parts)),
|
||||
}
|
||||
if err := s.userGroupRepo.Create(ctx, newUserGroup); err != nil {
|
||||
}); 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,
|
||||
})
|
||||
_ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{Namespace: "Tenant", Object: existingID, Relation: "parents", Subject: "Tenant:" + currentParentID, Action: domain.KetoOutboxActionCreate})
|
||||
}
|
||||
|
||||
existingID = unitID
|
||||
res.TenantCreated++
|
||||
}
|
||||
|
||||
cache[currentPath] = existingID
|
||||
cache[cacheKey] = 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"
|
||||
if total == 1 { return "Team" }
|
||||
if index == 0 { return "Division" }
|
||||
return "Team"
|
||||
}
|
||||
|
||||
239
backend/internal/service/org_chart_service_test.go
Normal file
239
backend/internal/service/org_chart_service_test.go
Normal file
@@ -0,0 +1,239 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/repository"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
type mockTenantRepo struct {
|
||||
mock.Mock
|
||||
repository.TenantRepository
|
||||
}
|
||||
|
||||
func (m *mockTenantRepo) FindBySlug(ctx context.Context, slug string) (*domain.Tenant, error) {
|
||||
args := m.Called(ctx, slug)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*domain.Tenant), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockTenantRepo) Create(ctx context.Context, tenant *domain.Tenant) error {
|
||||
args := m.Called(ctx, tenant)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *mockTenantRepo) AddDomain(ctx context.Context, tenantID, domainName string, isPrimary bool) error {
|
||||
args := m.Called(ctx, tenantID, domainName, isPrimary)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
type mockUserGroupRepo struct {
|
||||
mock.Mock
|
||||
repository.UserGroupRepository
|
||||
}
|
||||
|
||||
func (m *mockUserGroupRepo) ListByTenantID(ctx context.Context, tenantID string) ([]domain.UserGroup, error) {
|
||||
args := m.Called(ctx, tenantID)
|
||||
return args.Get(0).([]domain.UserGroup), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockUserGroupRepo) Create(ctx context.Context, ug *domain.UserGroup) error {
|
||||
args := m.Called(ctx, ug)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
type mockUserRepo struct {
|
||||
mock.Mock
|
||||
repository.UserRepository
|
||||
}
|
||||
|
||||
func (m *mockUserRepo) Update(ctx context.Context, user *domain.User) error {
|
||||
args := m.Called(ctx, user)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
type mockKetoOutboxRepo struct {
|
||||
mock.Mock
|
||||
repository.KetoOutboxRepository
|
||||
}
|
||||
|
||||
func (m *mockKetoOutboxRepo) Create(ctx context.Context, outbox *domain.KetoOutbox) error {
|
||||
args := m.Called(ctx, outbox)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
type mockKratosService struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *mockKratosService) ListIdentities(ctx context.Context) ([]KratosIdentity, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockKratosService) FindIdentityIDByIdentifier(ctx context.Context, identifier string) (string, error) {
|
||||
args := m.Called(ctx, identifier)
|
||||
return args.String(0), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockKratosService) GetIdentity(ctx context.Context, identityID string) (*KratosIdentity, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockKratosService) UpdateIdentity(ctx context.Context, id string, traits map[string]interface{}, state string) (*KratosIdentity, error) {
|
||||
args := m.Called(ctx, id, traits, state)
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockKratosService) UpdateIdentityPassword(ctx context.Context, id, pw string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockKratosService) DeleteIdentity(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockKratosService) CreateUser(ctx context.Context, user *domain.BrokerUser, password string) (string, error) {
|
||||
args := m.Called(ctx, user, password)
|
||||
return args.String(0), args.Error(1)
|
||||
}
|
||||
|
||||
func setupMocksForImport(tenantRepo *mockTenantRepo, ugRepo *mockUserGroupRepo, userRepo *mockUserRepo, ketoRepo *mockKetoOutboxRepo, kratos *mockKratosService, ctx context.Context, companySlug, email string) {
|
||||
tenantRepo.On("FindBySlug", ctx, companySlug).Return(&domain.Tenant{ID: companySlug + "-id", Slug: companySlug}, nil).Maybe()
|
||||
tenantRepo.On("FindBySlug", ctx, mock.Anything).Return(&domain.Tenant{ID: "fallback-id", Slug: "fallback"}, nil).Maybe()
|
||||
tenantRepo.On("AddDomain", ctx, mock.Anything, mock.Anything, true).Return(nil).Maybe()
|
||||
tenantRepo.On("Create", ctx, mock.Anything).Return(nil).Maybe()
|
||||
ugRepo.On("ListByTenantID", ctx, mock.Anything).Return([]domain.UserGroup{}, nil).Maybe()
|
||||
ugRepo.On("Create", ctx, mock.Anything).Return(nil).Maybe()
|
||||
kratos.On("FindIdentityIDByIdentifier", ctx, email).Return("user-id", nil).Maybe()
|
||||
kratos.On("UpdateIdentity", ctx, "user-id", mock.Anything, "active").Return(nil, nil).Maybe()
|
||||
userRepo.On("Update", ctx, mock.Anything).Return(nil).Maybe()
|
||||
ketoRepo.On("Create", ctx, mock.Anything).Return(nil).Maybe()
|
||||
}
|
||||
|
||||
func TestImportOrgChart_CSV_BOM(t *testing.T) {
|
||||
tenantRepo := new(mockTenantRepo)
|
||||
ugRepo := new(mockUserGroupRepo)
|
||||
userRepo := new(mockUserRepo)
|
||||
ketoRepo := new(mockKetoOutboxRepo)
|
||||
kratos := new(mockKratosService)
|
||||
svc := NewOrgChartService(tenantRepo, ugRepo, userRepo, ketoRepo, kratos)
|
||||
|
||||
csvData := "\xef\xbb\xbf이메일,이름,소속,직급,그룹,디비젼,팀,구분\n" +
|
||||
"test@example.com,홍길동,한맥,사원,엔지니어링,구조,계획,팀원"
|
||||
|
||||
ctx := context.Background()
|
||||
setupMocksForImport(tenantRepo, ugRepo, userRepo, ketoRepo, kratos, ctx, "hanmac", "test@example.com")
|
||||
|
||||
res, err := svc.ImportOrgChart(ctx, "root-id", bytes.NewReader([]byte(csvData)), "test.csv", "")
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, res)
|
||||
}
|
||||
|
||||
func TestImportOrgChart_XLSX(t *testing.T) {
|
||||
tenantRepo := new(mockTenantRepo)
|
||||
ugRepo := new(mockUserGroupRepo)
|
||||
userRepo := new(mockUserRepo)
|
||||
ketoRepo := new(mockKetoOutboxRepo)
|
||||
kratos := new(mockKratosService)
|
||||
svc := NewOrgChartService(tenantRepo, ugRepo, userRepo, ketoRepo, kratos)
|
||||
|
||||
xlsx := excelize.NewFile()
|
||||
xlsx.SetCellValue("Sheet1", "A1", "이메일")
|
||||
xlsx.SetCellValue("Sheet1", "B1", "이름")
|
||||
xlsx.SetCellValue("Sheet1", "C1", "소속")
|
||||
xlsx.SetCellValue("Sheet1", "A2", "xlsx@example.com")
|
||||
xlsx.SetCellValue("Sheet1", "B2", "엑셀맨")
|
||||
xlsx.SetCellValue("Sheet1", "C2", "삼안")
|
||||
|
||||
var buf bytes.Buffer
|
||||
xlsx.Write(&buf)
|
||||
|
||||
ctx := context.Background()
|
||||
setupMocksForImport(tenantRepo, ugRepo, userRepo, ketoRepo, kratos, ctx, "saman", "xlsx@example.com")
|
||||
|
||||
res, err := svc.ImportOrgChart(ctx, "root-id", &buf, "test.xlsx", "")
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, res)
|
||||
}
|
||||
|
||||
func TestImportOrgChart_MissingColumns(t *testing.T) {
|
||||
svc := NewOrgChartService(nil, nil, nil, nil, nil)
|
||||
ctx := context.Background()
|
||||
|
||||
csvData := "소속,직급\n한맥,부장"
|
||||
res, err := svc.ImportOrgChart(ctx, "root", bytes.NewReader([]byte(csvData)), "test.csv", "")
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, res)
|
||||
}
|
||||
|
||||
func TestImportOrgChart_RobustHeader(t *testing.T) {
|
||||
tenantRepo := new(mockTenantRepo)
|
||||
ugRepo := new(mockUserGroupRepo)
|
||||
userRepo := new(mockUserRepo)
|
||||
ketoRepo := new(mockKetoOutboxRepo)
|
||||
kratos := new(mockKratosService)
|
||||
svc := NewOrgChartService(tenantRepo, ugRepo, userRepo, ketoRepo, kratos)
|
||||
|
||||
csvData := "\n\n 이 메 일 , 이 름 , 소 속 \n" +
|
||||
"robust@example.com,로버스트,바론"
|
||||
ctx := context.Background()
|
||||
setupMocksForImport(tenantRepo, ugRepo, userRepo, ketoRepo, kratos, ctx, "baron", "robust@example.com")
|
||||
|
||||
res, err := svc.ImportOrgChart(ctx, "root-id", bytes.NewReader([]byte(csvData)), "test.csv", "")
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, res)
|
||||
}
|
||||
|
||||
func TestImportOrgChart_MultiSheet_ComplexHeaders(t *testing.T) {
|
||||
tenantRepo := new(mockTenantRepo)
|
||||
ugRepo := new(mockUserGroupRepo)
|
||||
userRepo := new(mockUserRepo)
|
||||
ketoRepo := new(mockKetoOutboxRepo)
|
||||
kratos := new(mockKratosService)
|
||||
svc := NewOrgChartService(tenantRepo, ugRepo, userRepo, ketoRepo, kratos)
|
||||
|
||||
xlsx := excelize.NewFile()
|
||||
xlsx.NewSheet("Sheet2")
|
||||
xlsx.SetCellValue("Sheet2", "A3", " 이 메 일 ")
|
||||
xlsx.SetCellValue("Sheet2", "B3", " 성 함 / 이 름 ")
|
||||
xlsx.SetCellValue("Sheet2", "C3", " 소 속 회 사 ")
|
||||
xlsx.SetCellValue("Sheet2", "A4", "sheet2@example.com")
|
||||
xlsx.SetCellValue("Sheet2", "B4", "시트투")
|
||||
xlsx.SetCellValue("Sheet2", "C4", "한맥")
|
||||
|
||||
var buf bytes.Buffer
|
||||
xlsx.Write(&buf)
|
||||
|
||||
ctx := context.Background()
|
||||
setupMocksForImport(tenantRepo, ugRepo, userRepo, ketoRepo, kratos, ctx, "hanmac", "sheet2@example.com")
|
||||
|
||||
res, err := svc.ImportOrgChart(ctx, "root-id", &buf, "test.xlsx", "")
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, res)
|
||||
}
|
||||
|
||||
func TestImportOrgChart_MessyHeader(t *testing.T) {
|
||||
tenantRepo := new(mockTenantRepo)
|
||||
ugRepo := new(mockUserGroupRepo)
|
||||
userRepo := new(mockUserRepo)
|
||||
ketoRepo := new(mockKetoOutboxRepo)
|
||||
kratos := new(mockKratosService)
|
||||
svc := NewOrgChartService(tenantRepo, ugRepo, userRepo, ketoRepo, kratos)
|
||||
|
||||
csvData := " 이메일(ID)* , 성 명 , [소속] \n" +
|
||||
"messy@example.com,메시,바론"
|
||||
ctx := context.Background()
|
||||
setupMocksForImport(tenantRepo, ugRepo, userRepo, ketoRepo, kratos, ctx, "baron", "messy@example.com")
|
||||
|
||||
res, err := svc.ImportOrgChart(ctx, "root-id", bytes.NewReader([]byte(csvData)), "test.csv", "")
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, res)
|
||||
}
|
||||
@@ -20,10 +20,12 @@ type TenantService interface {
|
||||
GetTenant(ctx context.Context, id string) (*domain.Tenant, error)
|
||||
ListTenants(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error)
|
||||
ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error)
|
||||
ListJoinedTenants(ctx context.Context, userID string) ([]domain.Tenant, error)
|
||||
IsDomainAllowed(ctx context.Context, domainName string) (bool, error)
|
||||
ApproveTenant(ctx context.Context, id string) error
|
||||
ProvisionTenantByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) // 추가
|
||||
SetKetoService(keto KetoService) // 추가
|
||||
ProvisionTenantByDomain(ctx context.Context, domainName string) (*domain.Tenant, error)
|
||||
SetKetoService(keto KetoService)
|
||||
DeleteTenantsBulk(ctx context.Context, ids []string) error
|
||||
}
|
||||
|
||||
type tenantService struct {
|
||||
@@ -56,8 +58,6 @@ func (s *tenantService) ListManageableTenants(ctx context.Context, userID string
|
||||
return nil, errors.New("keto service not initialized")
|
||||
}
|
||||
|
||||
// [Keto] 'Tenant' 네임스페이스에서 'manage' 권한을 가진 모든 테넌트 ID 조회
|
||||
// OPL(parents 상속 포함) 결과가 반영된 리스트를 가져옵니다.
|
||||
allIDs, err := s.keto.ListObjects(ctx, "Tenant", "manage", "User:"+userID)
|
||||
if err != nil {
|
||||
slog.Error("Failed to list manageable tenants from Keto", "userID", userID, "error", err)
|
||||
@@ -65,7 +65,6 @@ func (s *tenantService) ListManageableTenants(ctx context.Context, userID string
|
||||
}
|
||||
|
||||
if len(allIDs) == 0 {
|
||||
// Fallback: Check direct membership if list objects didn't catch everything
|
||||
directAdminIDs, _ := s.keto.ListObjects(ctx, "Tenant", "admins", "User:"+userID)
|
||||
directOwnerIDs, _ := s.keto.ListObjects(ctx, "Tenant", "owners", "User:"+userID)
|
||||
|
||||
@@ -90,13 +89,42 @@ func (s *tenantService) ListManageableTenants(ctx context.Context, userID string
|
||||
return s.repo.FindByIDs(ctx, allIDs)
|
||||
}
|
||||
|
||||
func (s *tenantService) ListJoinedTenants(ctx context.Context, userID string) ([]domain.Tenant, error) {
|
||||
if s.keto == nil {
|
||||
return nil, errors.New("keto service not initialized")
|
||||
}
|
||||
|
||||
memberIDs, err := s.keto.ListObjects(ctx, "Tenant", "members", "User:"+userID)
|
||||
if err != nil {
|
||||
slog.Error("Failed to list joined tenants from Keto", "userID", userID, "error", err)
|
||||
return []domain.Tenant{}, nil
|
||||
}
|
||||
|
||||
ownerIDs, _ := s.keto.ListObjects(ctx, "Tenant", "owners", "User:"+userID)
|
||||
adminIDs, _ := s.keto.ListObjects(ctx, "Tenant", "admins", "User:"+userID)
|
||||
|
||||
idMap := make(map[string]bool)
|
||||
for _, id := range memberIDs { idMap[id] = true }
|
||||
for _, id := range ownerIDs { idMap[id] = true }
|
||||
for _, id := range adminIDs { idMap[id] = true }
|
||||
|
||||
allIDs := make([]string, 0, len(idMap))
|
||||
for id := range idMap {
|
||||
allIDs = append(allIDs, id)
|
||||
}
|
||||
|
||||
if len(allIDs) == 0 {
|
||||
return []domain.Tenant{}, nil
|
||||
}
|
||||
|
||||
return s.repo.FindByIDs(ctx, allIDs)
|
||||
}
|
||||
|
||||
func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string, creatorID string) (*domain.Tenant, error) {
|
||||
// Validate Slug
|
||||
if ok, msg := utils.ValidateSlug(slug); !ok {
|
||||
return nil, errors.New(msg)
|
||||
}
|
||||
|
||||
// 1. Check if slug exists
|
||||
existing, err := s.repo.FindBySlug(ctx, slug)
|
||||
if err == nil && existing != nil {
|
||||
return nil, errors.New("tenant slug already exists")
|
||||
@@ -105,7 +133,6 @@ func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, tenantTy
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 2. Create Tenant
|
||||
tenant := &domain.Tenant{
|
||||
Type: tenantType,
|
||||
Name: name,
|
||||
@@ -119,9 +146,7 @@ func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, tenantTy
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// [Keto] Sync hierarchy and ownership via Outbox
|
||||
if s.outboxRepo != nil {
|
||||
// Global Super Admin access to every tenant
|
||||
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: tenant.ID,
|
||||
@@ -130,7 +155,6 @@ func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, tenantTy
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
|
||||
// Sync hierarchy
|
||||
if tenant.ParentID != nil {
|
||||
if err := s.outboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
@@ -143,10 +167,8 @@ func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, tenantTy
|
||||
}
|
||||
}
|
||||
|
||||
// Sync creator ownership
|
||||
if creatorID != "" {
|
||||
slog.Info("Creating outbox entries for tenant creator", "tenant", tenant.ID, "creator", creatorID)
|
||||
// Add as owner
|
||||
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: tenant.ID,
|
||||
@@ -154,7 +176,6 @@ func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, tenantTy
|
||||
Subject: "User:" + creatorID,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
// Add as admin
|
||||
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: tenant.ID,
|
||||
@@ -162,7 +183,6 @@ func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, tenantTy
|
||||
Subject: "User:" + creatorID,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
// Add as member
|
||||
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: tenant.ID,
|
||||
@@ -173,7 +193,6 @@ func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, tenantTy
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Add Domains (Auto-verify for manual admin registration)
|
||||
for _, d := range domains {
|
||||
if err := s.repo.AddDomain(ctx, tenant.ID, d, true); err != nil {
|
||||
slog.Error("Failed to add domain to tenant", "tenant", slug, "domain", d, "error", err)
|
||||
@@ -184,12 +203,10 @@ func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, tenantTy
|
||||
}
|
||||
|
||||
func (s *tenantService) RequestRegistration(ctx context.Context, name, slug, description string, domainName string, adminEmail string) (*domain.Tenant, error) {
|
||||
// Validate Slug
|
||||
if ok, msg := utils.ValidateSlug(slug); !ok {
|
||||
return nil, errors.New(msg)
|
||||
}
|
||||
|
||||
// Verify that adminEmail domain matches the requested domainName
|
||||
parts := strings.Split(adminEmail, "@")
|
||||
if len(parts) != 2 || parts[1] != domainName {
|
||||
return nil, errors.New("admin email domain must match the tenant domain")
|
||||
@@ -208,7 +225,6 @@ func (s *tenantService) RequestRegistration(ctx context.Context, name, slug, des
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// [Keto] Global Super Admin access to every tenant (even pending ones)
|
||||
if s.outboxRepo != nil {
|
||||
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
@@ -219,7 +235,6 @@ func (s *tenantService) RequestRegistration(ctx context.Context, name, slug, des
|
||||
})
|
||||
}
|
||||
|
||||
// Add Domain as unverified
|
||||
if err := s.repo.AddDomain(ctx, tenant.ID, domainName, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -238,15 +253,12 @@ func (s *tenantService) ApproveTenant(ctx context.Context, id string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// [Keto] Sync relation via Outbox
|
||||
if s.outboxRepo != nil {
|
||||
if adminEmail, ok := tenant.Config["adminEmail"].(string); ok && adminEmail != "" {
|
||||
slog.Info("Queueing tenant admin/owner sync to Keto", "tenant", tenant.Slug, "adminEmail", adminEmail)
|
||||
// Check if user already exists in our Read-Model
|
||||
if s.userRepo != nil {
|
||||
user, err := s.userRepo.FindByEmail(ctx, adminEmail)
|
||||
if err == nil && user != nil {
|
||||
// User exists, assign Admin, Owner, and Member roles in Keto via Outbox
|
||||
slog.Info("Queueing tenant ownership/membership sync to Keto", "tenant", tenant.Slug, "userID", user.ID)
|
||||
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
@@ -285,7 +297,6 @@ func (s *tenantService) GetTenantByDomain(ctx context.Context, emailDomain strin
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Only return ACTIVE tenants for auto-assignment
|
||||
if tenant.Status != domain.TenantStatusActive {
|
||||
return nil, errors.New("tenant is not active")
|
||||
}
|
||||
@@ -298,7 +309,6 @@ func (s *tenantService) GetTenantBySlug(ctx context.Context, slug string) (*doma
|
||||
}
|
||||
|
||||
func (s *tenantService) ListTenants(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) {
|
||||
// Let the repository handle the query and pagination
|
||||
return s.repo.List(ctx, limit, offset, parentID)
|
||||
}
|
||||
|
||||
@@ -314,14 +324,12 @@ func (s *tenantService) IsDomainAllowed(ctx context.Context, domainName string)
|
||||
}
|
||||
|
||||
func (s *tenantService) ProvisionTenantByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) {
|
||||
// 1. Find all COMPANY_GROUP tenants
|
||||
groups, err := s.repo.ListByType(ctx, domain.TenantTypeCompanyGroup)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, g := range groups {
|
||||
// 2. Check autoProvisioning config
|
||||
rawConfig, ok := g.Config["autoProvisioning"].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
@@ -337,7 +345,6 @@ func (s *tenantService) ProvisionTenantByDomain(ctx context.Context, domainName
|
||||
continue
|
||||
}
|
||||
|
||||
// 3. Find rule for this domain
|
||||
rule, ok := mapping[domainName].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
@@ -350,13 +357,32 @@ func (s *tenantService) ProvisionTenantByDomain(ctx context.Context, domainName
|
||||
continue
|
||||
}
|
||||
|
||||
// 4. Create new sub-tenant under this group
|
||||
slog.Info("[Provisioning] Found rule for domain, creating sub-tenant", "domain", domainName, "parent", g.Slug, "newTenant", slug)
|
||||
|
||||
// Use RegisterTenant to handle DB creation and Keto Outbox sync
|
||||
// creatorID is empty as per security policy (manual delegation later)
|
||||
return s.RegisterTenant(ctx, name, slug, domain.TenantTypeCompany, "Automatically provisioned via group policy", []string{domainName}, &g.ID, "")
|
||||
}
|
||||
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
|
||||
func (s *tenantService) DeleteTenantsBulk(ctx context.Context, ids []string) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := s.repo.DeleteBulk(ctx, ids); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if s.outboxRepo != nil {
|
||||
for _, id := range ids {
|
||||
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: id,
|
||||
Relation: "parents",
|
||||
Action: domain.KetoOutboxActionDelete,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -72,6 +72,11 @@ func (m *MockTenantRepoForSvc) ListByType(ctx context.Context, tenantType string
|
||||
return args.Get(0).([]domain.Tenant), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockTenantRepoForSvc) DeleteBulk(ctx context.Context, ids []string) error {
|
||||
args := m.Called(ctx, ids)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
type MockKetoSvcForTenant struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
@@ -170,6 +170,10 @@ func (m *MockTenantRepository) AddDomain(ctx context.Context, tenantID string, d
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockTenantRepository) DeleteBulk(ctx context.Context, ids []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestUserGroupService_Create(t *testing.T) {
|
||||
mockRepo := new(MockUserGroupRepository)
|
||||
mockTenantRepo := new(MockTenantRepository)
|
||||
|
||||
Reference in New Issue
Block a user