From 726ac71214a1bbbba595cda23b1cc0959be3e3cf Mon Sep 17 00:00:00 2001 From: chan Date: Wed, 15 Apr 2026 16:01:31 +0900 Subject: [PATCH] fix(user): preserve multi-tenant companyCodes and fix Kratos code parsing - UpdateUser: Implement 'Preserve & Merge' logic to fetch existing joined tenants from Keto and merge them with UI requests, preventing the loss of multi-tenant affiliations. - Keto Sync: Expand the self-healing background job to iterate over all companyCodes, ensuring 'members' relations are created for every joined tenant (fixes #554). - AuthHandler: Update extractFirstString to gracefully handle numeric JSON types, fixing an issue where Kratos login codes were lost during Courier webhook processing. --- backend/internal/handler/auth_handler.go | 7 ++ backend/internal/handler/user_handler.go | 81 +++++++++++++++++++++--- 2 files changed, 78 insertions(+), 10 deletions(-) diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 0622bf74..20e18d1b 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -3767,6 +3767,13 @@ func extractFirstString(data map[string]interface{}, keys ...string) string { if str, ok := val.(string); ok && str != "" { return str } + // Handle numeric types by converting to string + if num, ok := val.(float64); ok { + return fmt.Sprint(num) + } + if num, ok := val.(int); ok { + return fmt.Sprint(num) + } } } return "" diff --git a/backend/internal/handler/user_handler.go b/backend/internal/handler/user_handler.go index ea522069..f2c2a2da 100644 --- a/backend/internal/handler/user_handler.go +++ b/backend/internal/handler/user_handler.go @@ -1266,6 +1266,25 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error { if traits == nil { traits = map[string]interface{}{} } + + // [Preserve & Merge] Multi-Tenant Info + var existingCodes []string + if codes, ok := traits["companyCodes"].([]interface{}); ok { + for _, v := range codes { + if str, ok := v.(string); ok && str != "" { + existingCodes = append(existingCodes, str) + } + } + } + // Keto에서 "실제" 소속 정보를 먼저 확인 (엑셀 임포트 사용자 대응) + if len(existingCodes) <= 1 && h.TenantService != nil { + if joined, err := h.TenantService.ListJoinedTenants(c.Context(), userID); err == nil { + for _, t := range joined { + existingCodes = append(existingCodes, t.Slug) + } + } + } + if req.Name != nil { traits["name"] = strings.TrimSpace(*req.Name) } @@ -1286,7 +1305,33 @@ 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 { + if existing == code { + found = true + break + } + } + if !found && code != "" { + existingCodes = append(existingCodes, code) + } } + + // Deduplicate and save back companyCodes + var uniqueCodes []string + seenCodes := map[string]bool{} + for _, c := range existingCodes { + if !seenCodes[c] && c != "" { + seenCodes[c] = true + uniqueCodes = append(uniqueCodes, c) + } + } + if len(uniqueCodes) > 0 { + traits["companyCodes"] = uniqueCodes + } + if req.Department != nil { traits["department"] = strings.TrimSpace(*req.Department) } @@ -1420,16 +1465,32 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error { } } - // [Self-Healing] If the UI explicitly assigned the tenant, force a Keto relation sync. - // This fixes issues where local DB had the tenant, but Keto failed to create the relation previously. - if req.CompanyCode != nil && h.KetoOutboxRepo != nil && updatedLocalUser.TenantID != nil { - _ = h.KetoOutboxRepo.Create(bgCtx, &domain.KetoOutbox{ - Namespace: "Tenant", - Object: *updatedLocalUser.TenantID, - Relation: "members", - Subject: "User:" + updatedLocalUser.ID, - Action: domain.KetoOutboxActionCreate, - }) + // [Self-Healing] Sync all companyCodes to Keto + if h.KetoOutboxRepo != nil && h.TenantService != nil { + if codes, ok := updated.Traits["companyCodes"].([]interface{}); ok { + for _, cVal := range codes { + if cStr, ok := cVal.(string); ok && cStr != "" { + if tenant, err := h.TenantService.GetTenantBySlug(bgCtx, cStr); err == nil && tenant != nil { + _ = h.KetoOutboxRepo.Create(bgCtx, &domain.KetoOutbox{ + Namespace: "Tenant", + Object: tenant.ID, + Relation: "members", + Subject: "User:" + updatedLocalUser.ID, + Action: domain.KetoOutboxActionCreate, + }) + } + } + } + } else if updatedLocalUser.TenantID != nil { + // Fallback if companyCodes doesn't exist + _ = h.KetoOutboxRepo.Create(bgCtx, &domain.KetoOutbox{ + Namespace: "Tenant", + Object: *updatedLocalUser.TenantID, + Relation: "members", + Subject: "User:" + updatedLocalUser.ID, + Action: domain.KetoOutboxActionCreate, + }) + } } }() }