forked from baron/baron-sso
dev 브런치 반영 code-check 오류 수정
This commit is contained in:
@@ -1,18 +1,17 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/service"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/service"
|
||||
)
|
||||
|
||||
func main() {
|
||||
kratosAdmin := service.NewKratosAdminService()
|
||||
ctx := context.Background()
|
||||
|
||||
|
||||
identities, err := kratosAdmin.ListIdentities(ctx)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to list identities: %v", err)
|
||||
@@ -22,7 +21,7 @@ func main() {
|
||||
for _, id := range identities {
|
||||
traits := id.Traits
|
||||
changed := false
|
||||
|
||||
|
||||
if r, ok := traits["role"].(string); ok {
|
||||
norm := domain.NormalizeRole(r)
|
||||
if norm != r && norm == domain.RoleUser {
|
||||
|
||||
@@ -42,6 +42,6 @@ func migrateSchemas(db *gorm.DB) error {
|
||||
&domain.ClientConsent{},
|
||||
&domain.KetoOutbox{},
|
||||
&domain.SharedLink{},
|
||||
// &domain.RelyingParty{}, // Removed: SSOT is Hydra + Keto
|
||||
)
|
||||
}
|
||||
// &domain.RelyingParty{}, // Removed: SSOT is Hydra + Keto
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5413,7 +5413,7 @@ func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileRe
|
||||
profile.ManageableTenants = manageable
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
joined, err := h.TenantService.ListJoinedTenants(c.Context(), profile.ID)
|
||||
if err == nil {
|
||||
profile.JoinedTenants = joined
|
||||
|
||||
@@ -1519,7 +1519,7 @@ func TestRevokeHeadlessJWKSCache_DeletesCachedState(t *testing.T) {
|
||||
assert.Nil(t, stored)
|
||||
}
|
||||
|
||||
func TestListAuditLogs_TenantMemberForbidden(t *testing.T) {
|
||||
func TestListAuditLogs_TenantMemberWithoutAuditPermissionReturnsEmpty(t *testing.T) {
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{AdminURL: "http://hydra.test"},
|
||||
AuditRepo: &mockAuditRepo{},
|
||||
@@ -1540,7 +1540,11 @@ func TestListAuditLogs_TenantMemberForbidden(t *testing.T) {
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/audit-logs", nil)
|
||||
resp, _ := app.Test(req, -1)
|
||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result devAuditListResponse
|
||||
_ = json.NewDecoder(resp.Body).Decode(&result)
|
||||
assert.Empty(t, result.Items)
|
||||
}
|
||||
|
||||
func TestListAuditLogs_RPAdminScope(t *testing.T) {
|
||||
@@ -1915,6 +1919,20 @@ func TestRemoveClientRelation_RPAdminAllowedByManagePermission(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSearchUsers_RPAdminSearchByNameOrEmailWithinTenantScope(t *testing.T) {
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"client_id": "client-1",
|
||||
"client_name": "App One",
|
||||
"metadata": map[string]any{
|
||||
"tenant_id": "tenant-1",
|
||||
"status": "active",
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||
})
|
||||
|
||||
mockKratos := new(devMockKratosAdmin)
|
||||
mockKratos.On("ListIdentities", mock.Anything).Return([]service.KratosIdentity{
|
||||
{
|
||||
@@ -1938,6 +1956,10 @@ func TestSearchUsers_RPAdminSearchByNameOrEmailWithinTenantScope(t *testing.T) {
|
||||
}, nil)
|
||||
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
},
|
||||
KratosAdmin: mockKratos,
|
||||
}
|
||||
|
||||
@@ -1951,21 +1973,25 @@ func TestSearchUsers_RPAdminSearchByNameOrEmailWithinTenantScope(t *testing.T) {
|
||||
ManageableTenants: []domain.Tenant{
|
||||
{ID: "tenant-1", Slug: "tenant-one"},
|
||||
},
|
||||
Metadata: map[string]any{
|
||||
"managed_client_ids": []any{"client-1"},
|
||||
},
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/api/v1/dev/users", h.SearchUsers)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/users?search=alice", nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/users?clientId=client-1&search=alice", nil)
|
||||
resp, _ := app.Test(req, -1)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
var result devUserListResponse
|
||||
_ = json.NewDecoder(resp.Body).Decode(&result)
|
||||
assert.Len(t, result.Items, 1)
|
||||
assert.Equal(t, "user-1", result.Items[0].ID)
|
||||
assert.Equal(t, "Alice Kim", result.Items[0].Name)
|
||||
assert.Equal(t, "alice@example.com", result.Items[0].Email)
|
||||
if assert.Len(t, result.Items, 1) {
|
||||
assert.Equal(t, "user-1", result.Items[0].ID)
|
||||
assert.Equal(t, "Alice Kim", result.Items[0].Name)
|
||||
assert.Equal(t, "alice@example.com", result.Items[0].Email)
|
||||
}
|
||||
mockKratos.AssertExpectations(t)
|
||||
}
|
||||
|
||||
|
||||
@@ -868,7 +868,6 @@ func normalizeTenantType(value string) string {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func (h *TenantHandler) CreateShareLink(c *fiber.Ctx) error {
|
||||
tenantID := c.Params("id")
|
||||
var req struct {
|
||||
@@ -932,7 +931,9 @@ func (h *TenantHandler) GetPublicOrgChart(c *fiber.Ctx) error {
|
||||
curr := id
|
||||
for {
|
||||
p, exists := parentMap[curr]
|
||||
if !exists || p == "" { break }
|
||||
if !exists || p == "" {
|
||||
break
|
||||
}
|
||||
curr = p
|
||||
}
|
||||
return curr
|
||||
@@ -967,10 +968,14 @@ func (h *TenantHandler) GetPublicOrgChart(c *fiber.Ctx) error {
|
||||
var usersByID []domain.User
|
||||
h.DB.Where("tenant_id IN ?", tenantIDs).Preload("Tenant").Find(&usersByID)
|
||||
for _, u := range usersByID {
|
||||
if u.Status != "active" || seen[u.ID] { continue }
|
||||
if u.Status != "active" || seen[u.ID] {
|
||||
continue
|
||||
}
|
||||
seen[u.ID] = true
|
||||
cc := u.CompanyCode
|
||||
if cc == "" && u.Tenant != nil { cc = u.Tenant.Slug }
|
||||
if cc == "" && u.Tenant != nil {
|
||||
cc = u.Tenant.Slug
|
||||
}
|
||||
publicUsers = append(publicUsers, publicUserSummary{
|
||||
ID: u.ID, Name: u.Name, Position: u.Position, JobTitle: u.JobTitle, CompanyCode: cc, Status: u.Status,
|
||||
})
|
||||
@@ -980,10 +985,14 @@ func (h *TenantHandler) GetPublicOrgChart(c *fiber.Ctx) error {
|
||||
var usersBySlug []domain.User
|
||||
h.DB.Where("company_code IN ?", slugs).Preload("Tenant").Find(&usersBySlug)
|
||||
for _, u := range usersBySlug {
|
||||
if u.Status != "active" || seen[u.ID] { continue }
|
||||
if u.Status != "active" || seen[u.ID] {
|
||||
continue
|
||||
}
|
||||
seen[u.ID] = true
|
||||
cc := u.CompanyCode
|
||||
if cc == "" && u.Tenant != nil { cc = u.Tenant.Slug }
|
||||
if cc == "" && u.Tenant != nil {
|
||||
cc = u.Tenant.Slug
|
||||
}
|
||||
publicUsers = append(publicUsers, publicUserSummary{
|
||||
ID: u.ID, Name: u.Name, Position: u.Position, JobTitle: u.JobTitle, CompanyCode: cc, Status: u.Status,
|
||||
})
|
||||
@@ -995,8 +1004,8 @@ func (h *TenantHandler) GetPublicOrgChart(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"tenants": tenantSummaries,
|
||||
"users": publicUsers,
|
||||
"tenants": tenantSummaries,
|
||||
"users": publicUsers,
|
||||
"sharedWith": link.Name,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -204,24 +204,24 @@ func TestTenantHandler_ListTenants(t *testing.T) {
|
||||
}
|
||||
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
Role: "super_admin",
|
||||
})
|
||||
return c.Next()
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{
|
||||
Role: "super_admin",
|
||||
})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/tenants", h.ListTenants)
|
||||
tenants := []domain.Tenant{
|
||||
{ID: "t1", Name: "Tenant A", Slug: "slug-a"},
|
||||
{ID: "t2", Name: "Tenant B", Slug: "slug-b"},
|
||||
{ID: "t1", Name: "Tenant A", Slug: "slug-a"},
|
||||
{ID: "t2", Name: "Tenant B", Slug: "slug-b"},
|
||||
}
|
||||
|
||||
// Mocking for the new allTenants check in ListTenants
|
||||
mockSvc.On("ListTenants", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(tenants, int64(2), nil).Maybe()
|
||||
|
||||
mockUserRepo.On("CountByCompanyCodes", mock.Anything, mock.Anything).
|
||||
Return(map[string]int64{"slug-a": 5, "slug-b": 10}, nil).Maybe()
|
||||
Return(map[string]int64{"slug-a": 5, "slug-b": 10}, nil).Maybe()
|
||||
mockUserRepo.On("CountByTenantIDs", mock.Anything, mock.Anything).
|
||||
Return(map[string]int64{}, nil).Maybe()
|
||||
Return(map[string]int64{}, nil).Maybe()
|
||||
|
||||
req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil)
|
||||
resp, _ := app.Test(req)
|
||||
@@ -263,6 +263,7 @@ func (m *MockTenantService) DeleteTenantsBulk(ctx context.Context, tenantIDs []s
|
||||
args := m.Called(ctx, tenantIDs)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockTenantService) ListJoinedTenants(ctx context.Context, userID string) ([]domain.Tenant, error) {
|
||||
args := m.Called(ctx, userID)
|
||||
if args.Get(0) != nil {
|
||||
|
||||
@@ -133,7 +133,7 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
|
||||
parentMap[t.ID] = *t.ParentID
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Function to find the root of any given tenant
|
||||
findRoot := func(id string) string {
|
||||
curr := id
|
||||
@@ -331,17 +331,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"`
|
||||
Position string `json:"position"`
|
||||
JobTitle string `json:"jobTitle"`
|
||||
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")
|
||||
@@ -1305,7 +1305,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
traits["tenant_id"] = tenant.ID
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Add to existingCodes if not present
|
||||
found := false
|
||||
for _, existing := range existingCodes {
|
||||
|
||||
@@ -61,7 +61,7 @@ func (r *userRepository) Update(ctx context.Context, user *domain.User) error {
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Perform Upsert based on ID.
|
||||
// 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"}},
|
||||
|
||||
@@ -94,6 +94,7 @@ 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 {
|
||||
@@ -120,9 +121,11 @@ func (m *MockKratosAdminServiceShared) CreateUser(ctx context.Context, user *dom
|
||||
func (m *MockKratosAdminServiceShared) ListIdentitySessions(ctx context.Context, identityID string) ([]KratosSession, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockKratosAdminServiceShared) GetSession(ctx context.Context, sessionID string) (*KratosSession, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockKratosAdminServiceShared) DeleteSession(ctx context.Context, sessionID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -19,8 +19,10 @@ import (
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
var whitespaceRegex = regexp.MustCompile(`\s+`)
|
||||
var nonAlphaNumRegex = regexp.MustCompile(`[^a-zA-Z0-9가-힣]+`)
|
||||
var (
|
||||
whitespaceRegex = regexp.MustCompile(`\s+`)
|
||||
nonAlphaNumRegex = regexp.MustCompile(`[^a-zA-Z0-9가-힣]+`)
|
||||
)
|
||||
|
||||
type ProgressData struct {
|
||||
Current int `json:"current"`
|
||||
@@ -30,12 +32,12 @@ type ProgressData struct {
|
||||
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"`
|
||||
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 {
|
||||
@@ -86,13 +88,13 @@ func (s *orgChartService) ImportOrgChart(ctx context.Context, tenantID string, r
|
||||
}
|
||||
|
||||
fieldMapping := map[string][]string{
|
||||
"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"},
|
||||
"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
|
||||
@@ -102,11 +104,15 @@ func (s *orgChartService) ImportOrgChart(ctx context.Context, tenantID string, r
|
||||
|
||||
for sheetIdx, records := range allSheetsRecords {
|
||||
for i, row := range records {
|
||||
if len(row) < 2 { continue }
|
||||
if len(row) < 2 {
|
||||
continue
|
||||
}
|
||||
tempMap := make(map[string]int)
|
||||
for j, cell := range row {
|
||||
clean := s.cleanHeader(cell)
|
||||
if clean != "" { tempMap[clean] = j }
|
||||
if clean != "" {
|
||||
tempMap[clean] = j
|
||||
}
|
||||
}
|
||||
emailIdx := s.findBestMatch(tempMap, fieldMapping["email"])
|
||||
nameIdx := s.findBestMatch(tempMap, fieldMapping["name"])
|
||||
@@ -114,7 +120,8 @@ func (s *orgChartService) ImportOrgChart(ctx context.Context, tenantID string, r
|
||||
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
|
||||
emailIdx = j
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -124,13 +131,17 @@ func (s *orgChartService) ImportOrgChart(ctx context.Context, tenantID string, r
|
||||
for key, aliases := range fieldMapping {
|
||||
actualMap[key] = s.findBestMatch(tempMap, aliases)
|
||||
}
|
||||
if actualMap["email"] == -1 { actualMap["email"] = emailIdx }
|
||||
if actualMap["email"] == -1 {
|
||||
actualMap["email"] = emailIdx
|
||||
}
|
||||
found = true
|
||||
slog.Info("Found header row", "sheet", sheetIdx, "row", i)
|
||||
break
|
||||
}
|
||||
}
|
||||
if found { break }
|
||||
if found {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
@@ -173,19 +184,25 @@ func (s *orgChartService) ImportOrgChart(ctx context.Context, tenantID string, r
|
||||
}
|
||||
|
||||
for rowIdx, record := range dataRows {
|
||||
if len(record) == 0 { continue }
|
||||
if len(record) == 0 {
|
||||
continue
|
||||
}
|
||||
email := s.getVal(record, actualMap["email"])
|
||||
name := s.getVal(record, actualMap["name"])
|
||||
if email == "" || name == "" { continue }
|
||||
if email == "" || name == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
position := s.getVal(record, actualMap["position"])
|
||||
jobTitle := s.getVal(record, actualMap["jobtitle"])
|
||||
phone := s.normalizePhone(s.getVal(record, actualMap["phone"]))
|
||||
|
||||
companyName := s.getVal(record, actualMap["company"])
|
||||
if companyName == "" { companyName = "Main" }
|
||||
if companyName == "" {
|
||||
companyName = "Main"
|
||||
}
|
||||
companySlug := s.generateCompanySlug(companyName)
|
||||
|
||||
|
||||
companyTenantID, err := s.ensureCompanyTenant(ctx, tenantID, companyName, companySlug, email, pathCache, result)
|
||||
if err != nil {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("Row %d: Company fail: %v", rowIdx+2, err))
|
||||
@@ -196,8 +213,8 @@ func (s *orgChartService) ImportOrgChart(ctx context.Context, tenantID string, r
|
||||
var orgParts []string
|
||||
for _, idx := range hierarchyIdx {
|
||||
val := s.getVal(record, idx)
|
||||
if val != "" && val != "-" {
|
||||
orgParts = append(orgParts, val)
|
||||
if val != "" && val != "-" {
|
||||
orgParts = append(orgParts, val)
|
||||
}
|
||||
}
|
||||
orgPath := strings.Join(orgParts, " > ")
|
||||
@@ -217,9 +234,9 @@ func (s *orgChartService) ImportOrgChart(ctx context.Context, tenantID string, r
|
||||
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, "본부장")
|
||||
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)
|
||||
@@ -231,7 +248,7 @@ func (s *orgChartService) ImportOrgChart(ctx context.Context, tenantID string, r
|
||||
brokerUser := &domain.BrokerUser{
|
||||
Email: email, Name: name, PhoneNumber: phone,
|
||||
Attributes: map[string]interface{}{
|
||||
"affiliationType": "AFFILIATE", "companyCode": companySlug,
|
||||
"affiliationType": "AFFILIATE", "companyCode": companySlug,
|
||||
"department": orgPath, "grade": grade, "position": position,
|
||||
"tenant_id": leafID, // [Matrix Fix] Sync leafID to Kratos to prevent lazy sync overwrite
|
||||
},
|
||||
@@ -244,7 +261,7 @@ func (s *orgChartService) ImportOrgChart(ctx context.Context, tenantID string, r
|
||||
result.UserCreated++
|
||||
} else {
|
||||
traits := map[string]interface{}{
|
||||
"name": name, "companyCode": companySlug, "department": orgPath,
|
||||
"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
|
||||
}
|
||||
@@ -257,8 +274,8 @@ func (s *orgChartService) ImportOrgChart(ctx context.Context, tenantID string, r
|
||||
|
||||
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
|
||||
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 {
|
||||
@@ -269,31 +286,31 @@ func (s *orgChartService) ImportOrgChart(ctx context.Context, tenantID string, r
|
||||
if s.ketoOutboxRepo != nil {
|
||||
// 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,
|
||||
})
|
||||
|
||||
// 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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
// 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,
|
||||
})
|
||||
}
|
||||
@@ -315,26 +332,32 @@ func (s *orgChartService) cleanHeader(val string) string {
|
||||
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 }
|
||||
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 }
|
||||
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 "" }
|
||||
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, "")
|
||||
|
||||
@@ -354,13 +377,15 @@ func (s *orgChartService) normalizePhone(phone string) string {
|
||||
}
|
||||
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 }
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reader := csv.NewReader(bytes.NewReader(bytes.TrimPrefix(data, []byte("\xef\xbb\xbf"))))
|
||||
reader.LazyQuotes = true
|
||||
reader.FieldsPerRecord = -1
|
||||
@@ -369,11 +394,15 @@ func (s *orgChartService) readCSV(r io.Reader) ([][]string, error) {
|
||||
|
||||
func (s *orgChartService) readAllXLSXSheets(r io.Reader) ([][][]string, error) {
|
||||
f, err := excelize.OpenReader(r)
|
||||
if err != nil { return nil, err }
|
||||
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) }
|
||||
if rows, err := f.GetRows(sheet); err == nil {
|
||||
allRecords = append(allRecords, rows)
|
||||
}
|
||||
}
|
||||
return allRecords, nil
|
||||
}
|
||||
@@ -381,18 +410,22 @@ func (s *orgChartService) readAllXLSXSheets(r io.Reader) ([][][]string, error) {
|
||||
func (s *orgChartService) generateCompanySlug(name string) string {
|
||||
n := strings.ToLower(whitespaceRegex.ReplaceAllString(name, ""))
|
||||
slugs := map[string]string{
|
||||
"한맥": "hanmac", "삼안": "saman", "장헌": "jangheon",
|
||||
"한맥": "hanmac", "삼안": "saman", "장헌": "jangheon",
|
||||
"ptc": "ptc", "피티씨": "ptc", "바론": "baron", "한라": "halla",
|
||||
}
|
||||
for k, v := range slugs {
|
||||
if strings.Contains(n, k) || strings.Contains(n, v) { return v }
|
||||
if strings.Contains(n, k) || strings.Contains(n, v) {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return utils.GenerateSlug(name)
|
||||
}
|
||||
|
||||
func isAlphaNumeric(s string) bool {
|
||||
for _, r := range s {
|
||||
if (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '-' { return false }
|
||||
if (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '-' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -411,8 +444,10 @@ func (s *orgChartService) ensureCompanyTenant(ctx context.Context, rootID, name,
|
||||
}
|
||||
|
||||
cacheKey := "company:" + slug
|
||||
if id, ok := cache[cacheKey]; ok { return id, nil }
|
||||
|
||||
if id, ok := cache[cacheKey]; ok {
|
||||
return id, nil
|
||||
}
|
||||
|
||||
tenant, _ := s.tenantRepo.FindBySlug(ctx, slug)
|
||||
if tenant == nil {
|
||||
tenant, _ = s.tenantRepo.FindByName(ctx, name)
|
||||
@@ -420,17 +455,23 @@ func (s *orgChartService) ensureCompanyTenant(ctx context.Context, rootID, name,
|
||||
|
||||
if tenant == nil {
|
||||
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 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:" + rootID, Action: domain.KetoOutboxActionCreate})
|
||||
}
|
||||
res.TenantCreated++
|
||||
}
|
||||
|
||||
|
||||
domainPart := ""
|
||||
if parts := strings.Split(email, "@"); len(parts) == 2 { domainPart = parts[1] }
|
||||
if domainPart != "" { _ = s.tenantRepo.AddDomain(ctx, tenant.ID, domainPart, true) }
|
||||
|
||||
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
|
||||
}
|
||||
@@ -440,12 +481,19 @@ func (s *orgChartService) ensureOrgPath(ctx context.Context, rootTenantID string
|
||||
currentPath := ""
|
||||
for i, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" || part == "-" { continue }
|
||||
if currentPath == "" { currentPath = part } else { currentPath += "/" + part }
|
||||
|
||||
if part == "" || part == "-" {
|
||||
continue
|
||||
}
|
||||
if currentPath == "" {
|
||||
currentPath = part
|
||||
} else {
|
||||
currentPath += "/" + part
|
||||
}
|
||||
|
||||
cacheKey := rootTenantID + ":" + currentPath
|
||||
if id, ok := cache[cacheKey]; ok {
|
||||
currentParentID = id; continue
|
||||
currentParentID = id
|
||||
continue
|
||||
}
|
||||
|
||||
var existingID string
|
||||
@@ -454,7 +502,8 @@ func (s *orgChartService) ensureOrgPath(ctx context.Context, rootTenantID string
|
||||
isTopMatch := (g.ParentID == nil && currentParentID == rootTenantID)
|
||||
isSubMatch := (g.ParentID != nil && *g.ParentID == currentParentID)
|
||||
if g.Name == part && (isTopMatch || isSubMatch) {
|
||||
existingID = g.ID; break
|
||||
existingID = g.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -464,16 +513,16 @@ func (s *orgChartService) ensureOrgPath(ctx context.Context, rootTenantID string
|
||||
groupSlug := fmt.Sprintf("ug-%s", existingID[:13])
|
||||
|
||||
if err := s.tenantRepo.Create(ctx, &domain.Tenant{
|
||||
ID: existingID,
|
||||
Type: domain.TenantTypeUserGroup,
|
||||
ParentID: ¤tParentID,
|
||||
Name: part,
|
||||
Slug: groupSlug,
|
||||
ID: existingID,
|
||||
Type: domain.TenantTypeUserGroup,
|
||||
ParentID: ¤tParentID,
|
||||
Name: part,
|
||||
Slug: groupSlug,
|
||||
Status: domain.TenantStatusActive,
|
||||
}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
|
||||
var ugParentID *string
|
||||
if currentParentID != rootTenantID {
|
||||
pid := currentParentID
|
||||
@@ -481,10 +530,10 @@ func (s *orgChartService) ensureOrgPath(ctx context.Context, rootTenantID string
|
||||
}
|
||||
|
||||
if err := s.userGroupRepo.Create(ctx, &domain.UserGroup{
|
||||
ID: existingID,
|
||||
TenantID: rootTenantID,
|
||||
ParentID: ugParentID,
|
||||
Name: part,
|
||||
ID: existingID,
|
||||
TenantID: rootTenantID,
|
||||
ParentID: ugParentID,
|
||||
Name: part,
|
||||
UnitType: s.guessUnitType(i, len(parts)),
|
||||
}); err != nil {
|
||||
return "", err
|
||||
@@ -501,7 +550,11 @@ func (s *orgChartService) ensureOrgPath(ctx context.Context, rootTenantID string
|
||||
}
|
||||
|
||||
func (s *orgChartService) guessUnitType(index, total int) string {
|
||||
if total == 1 { return "Team" }
|
||||
if index == 0 { return "Division" }
|
||||
if total == 1 {
|
||||
return "Team"
|
||||
}
|
||||
if index == 0 {
|
||||
return "Division"
|
||||
}
|
||||
return "Team"
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/repository"
|
||||
"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"
|
||||
@@ -241,9 +241,11 @@ func TestImportOrgChart_MessyHeader(t *testing.T) {
|
||||
func (m *mockKratosService) ListIdentitySessions(ctx context.Context, identityID string) ([]KratosSession, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockKratosService) GetSession(ctx context.Context, sessionID string) (*KratosSession, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockKratosService) DeleteSession(ctx context.Context, sessionID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -104,9 +104,15 @@ func (s *tenantService) ListJoinedTenants(ctx context.Context, userID string) ([
|
||||
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 }
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user