1
0
forked from baron/baron-sso

네이버 계정 정합성 맞춤

This commit is contained in:
2026-06-15 19:54:09 +09:00
parent 8e9d015443
commit 4d468cd39f
97 changed files with 5837 additions and 2031 deletions

View File

@@ -16,10 +16,8 @@ import (
"io"
"log/slog"
"maps"
"os"
"reflect"
"sort"
"strconv"
"strings"
"time"
@@ -29,25 +27,28 @@ import (
)
type TenantHandler struct {
DB *gorm.DB
Service service.TenantService
UserRepo repository.UserRepository
UserProjectionRepo repository.UserProjectionRepository
OrgChartCache orgChartCacheStore
Keto service.KetoService
KetoOutbox repository.KetoOutboxRepository
KratosAdmin service.KratosAdminService
SharedLink service.SharedLinkService
Worksmobile service.WorksmobileSyncer
Hydra *service.HydraAdminService
ConsentRepo repository.ClientConsentRepository
DB *gorm.DB
Service service.TenantService
UserRepo repository.UserRepository
OrgChartCache orgChartCacheStore
IdentityCache domain.RedisRepository
Keto service.KetoService
KetoOutbox repository.KetoOutboxRepository
KratosAdmin service.KratosAdminService
SharedLink service.SharedLinkService
Worksmobile service.WorksmobileSyncer
Hydra *service.HydraAdminService
ConsentRepo repository.ClientConsentRepository
}
type orgChartCacheStore interface {
Get(key string) (string, error)
Set(key string, value string, expiration time.Duration) error
DeleteByPrefix(ctx context.Context, prefix string) (int64, error)
}
const orgChartSnapshotCacheKeyPrefix = "orgchart:snapshot:v1:"
func seedTenantDeleteError(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusConflict, "seed tenants cannot be deleted")
}
@@ -65,18 +66,17 @@ func seedTenantSlugsForDeleteGuard() []string {
return result
}
func NewTenantHandler(db *gorm.DB, svc service.TenantService, userRepo repository.UserRepository, userProjectionRepo repository.UserProjectionRepository, keto service.KetoService, outbox repository.KetoOutboxRepository, kratos service.KratosAdminService, sharedLink service.SharedLinkService, hydra *service.HydraAdminService, consentRepo repository.ClientConsentRepository) *TenantHandler {
func NewTenantHandler(db *gorm.DB, svc service.TenantService, userRepo repository.UserRepository, keto service.KetoService, outbox repository.KetoOutboxRepository, kratos service.KratosAdminService, sharedLink service.SharedLinkService, hydra *service.HydraAdminService, consentRepo repository.ClientConsentRepository) *TenantHandler {
return &TenantHandler{
DB: db,
Service: svc,
UserRepo: userRepo,
UserProjectionRepo: userProjectionRepo,
Keto: keto,
KetoOutbox: outbox,
KratosAdmin: kratos,
SharedLink: sharedLink,
Hydra: hydra,
ConsentRepo: consentRepo,
DB: db,
Service: svc,
UserRepo: userRepo,
Keto: keto,
KetoOutbox: outbox,
KratosAdmin: kratos,
SharedLink: sharedLink,
Hydra: hydra,
ConsentRepo: consentRepo,
}
}
@@ -263,6 +263,7 @@ func (h *TenantHandler) ApproveTenant(c *fiber.Ctx) error {
if err := h.Service.ApproveTenant(c.Context(), tenantID); err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
h.refreshOrgChartSnapshotCacheAfterTenantChange(c.Context(), "tenant_approved")
return c.JSON(fiber.Map{"message": "Tenant approved successfully"})
}
@@ -311,12 +312,25 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
}
parentMap := make(map[string]string)
tenantIDs := make(map[string]bool, len(allTenants))
for _, t := range allTenants {
tenantIDs[t.ID] = true
if t.ParentID != nil {
parentMap[t.ID] = *t.ParentID
}
}
hasValidBaseTenant := false
for _, id := range baseTenantIDs {
if tenantIDs[strings.TrimSpace(id)] {
hasValidBaseTenant = true
break
}
}
if len(baseTenantIDs) > 0 && !hasValidBaseTenant {
return errorJSON(c, fiber.StatusConflict, "tenant scope is not available")
}
roots := make(map[string]bool)
for _, id := range baseTenantIDs {
roots[findTenantRootID(parentMap, id)] = true
@@ -372,7 +386,7 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
}
}
memberCounts, totalMemberCounts, err := h.countTenantMembersFromProjection(c.Context(), tenants)
memberCounts, totalMemberCounts, err := h.countTenantMembers(c.Context(), tenants)
if err != nil {
return errorJSON(c, fiber.StatusServiceUnavailable, err.Error())
}
@@ -688,6 +702,9 @@ func (h *TenantHandler) ImportTenantsCSV(c *fiber.Ctx) error {
}
result.Details = append(result.Details, detail)
}
if result.Created > 0 || result.Updated > 0 {
h.refreshOrgChartSnapshotCacheAfterTenantChange(c.Context(), "tenants_imported")
}
return c.JSON(result)
}
@@ -1247,6 +1264,9 @@ func (h *TenantHandler) canViewPrivateTenant(ctx context.Context, profile *domai
for _, relation := range []string{"view_private", "view_private_descendants", "view", "manage"} {
allowed, err := h.Keto.CheckPermission(ctx, subject, "Tenant", privateRootID, relation)
if err != nil {
if isMissingKetoRelationError(err) {
continue
}
return false, fmt.Errorf("private tenant permission check failed: %w", err)
}
if allowed {
@@ -1257,6 +1277,9 @@ func (h *TenantHandler) canViewPrivateTenant(ctx context.Context, profile *domai
for _, ancestorID := range tenantAncestorIDs(privateRootID, tenants) {
allowed, err := h.Keto.CheckPermission(ctx, subject, "Tenant", ancestorID, "view_private_descendants")
if err != nil {
if isMissingKetoRelationError(err) {
continue
}
return false, fmt.Errorf("private tenant descendant permission check failed: %w", err)
}
if allowed {
@@ -1266,6 +1289,14 @@ func (h *TenantHandler) canViewPrivateTenant(ctx context.Context, profile *domai
return false, nil
}
func isMissingKetoRelationError(err error) bool {
if err == nil {
return false
}
message := strings.ToLower(err.Error())
return strings.Contains(message, "relation ") && strings.Contains(message, "does not exist")
}
func profileCanManageTenantOrAncestor(profile *domain.UserProfileResponse, tenantID string, tenants []domain.Tenant) bool {
manageableIDs := make(map[string]bool, len(profile.ManageableTenants))
for _, tenant := range profile.ManageableTenants {
@@ -1669,7 +1700,7 @@ func (h *TenantHandler) GetTenant(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
memberCounts, totalMemberCounts, err := h.countTenantMembersFromProjection(c.Context(), []domain.Tenant{tenant})
memberCounts, totalMemberCounts, err := h.countTenantMembers(c.Context(), []domain.Tenant{tenant})
if err != nil {
return errorJSON(c, fiber.StatusServiceUnavailable, err.Error())
}
@@ -1795,6 +1826,7 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error {
}
}
}
h.refreshOrgChartSnapshotCacheAfterTenantChange(c.Context(), "tenant_created")
return c.Status(fiber.StatusCreated).JSON(summary)
}
@@ -1955,6 +1987,7 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
fmt.Printf("[TenantHandler] failed to enqueue Worksmobile tenant update sync: %v\n", err)
}
}
h.refreshOrgChartSnapshotCacheAfterTenantChange(c.Context(), "tenant_updated")
return c.JSON(mapTenantSummary(tenant))
}
@@ -1980,6 +2013,10 @@ func (h *TenantHandler) DeleteTenant(c *fiber.Ctx) error {
return seedTenantDeleteError(c)
}
if err := h.reassignUserMembershipsBeforeTenantDelete(c.Context(), []string{tenantID}); err != nil {
return errorJSON(c, fiber.StatusConflict, err.Error())
}
if err := cleanupDeletedTenantReferences(c.Context(), h.Hydra, h.ConsentRepo, h.KetoOutbox, []string{tenantID}); err != nil {
logTenantCleanupFailure(err, []string{tenantID})
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
@@ -1999,6 +2036,7 @@ func (h *TenantHandler) DeleteTenant(c *fiber.Ctx) error {
fmt.Printf("[TenantHandler] failed to enqueue Worksmobile tenant delete sync: %v\n", err)
}
}
h.refreshOrgChartSnapshotCacheAfterTenantChange(c.Context(), "tenant_deleted")
return c.SendStatus(fiber.StatusNoContent)
}
@@ -2287,6 +2325,10 @@ func (h *TenantHandler) DeleteTenantsBulk(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
}
if len(req.IDs) == 0 {
return errorJSON(c, fiber.StatusBadRequest, "no IDs provided")
}
req.IDs = uniqueNonEmptyStrings(req.IDs)
if len(req.IDs) == 0 {
return errorJSON(c, fiber.StatusBadRequest, "no IDs provided")
}
@@ -2313,6 +2355,10 @@ func (h *TenantHandler) DeleteTenantsBulk(c *fiber.Ctx) error {
}
}
if err := h.reassignUserMembershipsBeforeTenantDelete(c.Context(), req.IDs); err != nil {
return errorJSON(c, fiber.StatusConflict, err.Error())
}
if err := cleanupDeletedTenantReferences(c.Context(), h.Hydra, h.ConsentRepo, h.KetoOutbox, req.IDs); err != nil {
logTenantCleanupFailure(err, req.IDs)
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
@@ -2321,6 +2367,7 @@ func (h *TenantHandler) DeleteTenantsBulk(c *fiber.Ctx) error {
if err := h.Service.DeleteTenantsBulk(c.Context(), req.IDs); err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
h.refreshOrgChartSnapshotCacheAfterTenantChange(c.Context(), "tenants_bulk_deleted")
return c.Status(fiber.StatusOK).JSON(fiber.Map{
"message": "Tenants deleted successfully",
@@ -2328,6 +2375,362 @@ func (h *TenantHandler) DeleteTenantsBulk(c *fiber.Ctx) error {
})
}
func uniqueNonEmptyStrings(values []string) []string {
seen := make(map[string]bool, len(values))
result := make([]string, 0, len(values))
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed == "" || seen[trimmed] {
continue
}
seen[trimmed] = true
result = append(result, trimmed)
}
return result
}
func (h *TenantHandler) reassignUserMembershipsBeforeTenantDelete(ctx context.Context, tenantIDs []string) error {
if h == nil || h.DB == nil {
return nil
}
deletedIDs := uniqueNonEmptyStrings(tenantIDs)
if len(deletedIDs) == 0 {
return nil
}
affectedIDs, err := h.deletedTenantIDsReferencedByUsers(ctx, deletedIDs)
if err != nil {
return err
}
if len(affectedIDs) == 0 {
return nil
}
var tenants []domain.Tenant
if err := h.DB.WithContext(ctx).Unscoped().Find(&tenants).Error; err != nil {
return err
}
targets, err := resolveTenantDeletionPromotionTargets(tenants, deletedIDs, affectedIDs)
if err != nil {
return err
}
var affectedUsers []domain.User
if err := h.DB.WithContext(ctx).
Where("tenant_id IN ?", affectedIDs).
Find(&affectedUsers).Error; err != nil {
return err
}
tenantByID := make(map[string]domain.Tenant, len(tenants))
for _, tenant := range tenants {
tenantByID[tenant.ID] = tenant
}
if err := h.promoteKratosUserMembershipsForTenantDelete(ctx, affectedUsers, tenantByID, targets); err != nil {
return err
}
return h.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
for deletedID, targetID := range targets {
if err := tx.Model(&domain.User{}).
Where("tenant_id = ?", deletedID).
Updates(map[string]any{"tenant_id": targetID, "updated_at": time.Now()}).Error; err != nil {
return err
}
if err := tx.Model(&domain.UserLoginID{}).
Where("tenant_id = ?", deletedID).
Update("tenant_id", targetID).Error; err != nil {
return err
}
}
return nil
})
}
func (h *TenantHandler) promoteKratosUserMembershipsForTenantDelete(ctx context.Context, users []domain.User, tenantByID map[string]domain.Tenant, targets map[string]string) error {
if h == nil || h.KratosAdmin == nil {
return nil
}
for _, user := range users {
if user.TenantID == nil || strings.TrimSpace(user.ID) == "" {
continue
}
deletedID := strings.TrimSpace(*user.TenantID)
targetID := strings.TrimSpace(targets[deletedID])
if deletedID == "" || targetID == "" {
continue
}
deletedTenant, ok := tenantByID[deletedID]
if !ok {
return fmt.Errorf("deleted tenant not found while promoting user membership: %s", deletedID)
}
targetTenant, ok := tenantByID[targetID]
if !ok {
return fmt.Errorf("promotion target tenant not found while promoting user membership: %s", targetID)
}
identity, err := h.KratosAdmin.GetIdentity(ctx, user.ID)
if err != nil {
return fmt.Errorf("get kratos identity for tenant promotion user=%s: %w", user.ID, err)
}
if identity == nil {
return fmt.Errorf("kratos identity not found for tenant promotion user=%s", user.ID)
}
traits, changed := promoteIdentityTraitsFromDeletedTenant(identity.Traits, deletedTenant, targetTenant, true)
if !changed {
continue
}
updated, err := h.KratosAdmin.UpdateIdentity(ctx, user.ID, traits, identity.State)
if err != nil {
return fmt.Errorf("update kratos identity for tenant promotion user=%s: %w", user.ID, err)
}
if updated == nil {
identity.Traits = traits
h.storePromotedIdentityMirror(*identity)
continue
}
h.storePromotedIdentityMirror(*updated)
}
return nil
}
func promoteIdentityTraitsFromDeletedTenant(traits map[string]any, deletedTenant domain.Tenant, targetTenant domain.Tenant, forcePrimary bool) (map[string]any, bool) {
next := cloneIdentityTraits(traits)
changed := false
primaryChanged := forcePrimary
if traitStringEqualTenant(next["tenant_id"], deletedTenant.ID, deletedTenant.Slug) || forcePrimary {
next["tenant_id"] = targetTenant.ID
changed = true
primaryChanged = true
}
if traitStringEqualTenant(next["primaryTenantId"], deletedTenant.ID, deletedTenant.Slug) || forcePrimary {
next["primaryTenantId"] = targetTenant.ID
changed = true
primaryChanged = true
}
if traitStringEqualTenant(next["primaryTenantSlug"], deletedTenant.ID, deletedTenant.Slug) || forcePrimary {
next["primaryTenantSlug"] = targetTenant.Slug
changed = true
primaryChanged = true
}
if traitStringEqualTenant(next["primaryTenantName"], deletedTenant.Name, "") || forcePrimary {
next["primaryTenantName"] = targetTenant.Name
changed = true
primaryChanged = true
}
if traitStringEqualTenant(next["companyCode"], deletedTenant.ID, deletedTenant.Slug) {
next["companyCode"] = targetTenant.Slug
changed = true
}
if traitStringEqualTenant(next["company_code"], deletedTenant.ID, deletedTenant.Slug) {
next["company_code"] = targetTenant.Slug
changed = true
}
appointments, appointmentsChanged := promoteIdentityAppointmentsFromDeletedTenant(next["additionalAppointments"], deletedTenant, targetTenant)
if appointmentsChanged {
next["additionalAppointments"] = appointments
changed = true
}
if primaryChanged {
next["primaryTenantId"] = targetTenant.ID
next["primaryTenantSlug"] = targetTenant.Slug
next["primaryTenantName"] = targetTenant.Name
}
return next, changed
}
func promoteIdentityAppointmentsFromDeletedTenant(raw any, deletedTenant domain.Tenant, targetTenant domain.Tenant) ([]any, bool) {
switch appointments := raw.(type) {
case []any:
next := make([]any, 0, len(appointments))
changed := false
for _, rawAppointment := range appointments {
appointment, ok := rawAppointment.(map[string]any)
if !ok {
next = append(next, rawAppointment)
continue
}
copied := maps.Clone(appointment)
if identityAppointmentMatchesTenant(copied, deletedTenant) {
copied["tenantId"] = targetTenant.ID
copied["tenantSlug"] = targetTenant.Slug
copied["tenantName"] = targetTenant.Name
changed = true
}
next = append(next, copied)
}
return next, changed
case []map[string]any:
next := make([]any, 0, len(appointments))
changed := false
for _, appointment := range appointments {
copied := maps.Clone(appointment)
if identityAppointmentMatchesTenant(copied, deletedTenant) {
copied["tenantId"] = targetTenant.ID
copied["tenantSlug"] = targetTenant.Slug
copied["tenantName"] = targetTenant.Name
changed = true
}
next = append(next, copied)
}
return next, changed
default:
return nil, false
}
}
func identityAppointmentMatchesTenant(appointment map[string]any, tenant domain.Tenant) bool {
return traitStringEqualTenant(appointment["tenantId"], tenant.ID, tenant.Slug) ||
traitStringEqualTenant(appointment["tenantSlug"], tenant.ID, tenant.Slug) ||
traitStringEqualTenant(appointment["tenantName"], tenant.Name, "")
}
func traitStringEqualTenant(value any, id string, slug string) bool {
raw := strings.TrimSpace(fmt.Sprint(value))
if raw == "" || raw == "<nil>" {
return false
}
if strings.EqualFold(raw, strings.TrimSpace(id)) {
return true
}
return strings.TrimSpace(slug) != "" && strings.EqualFold(raw, strings.TrimSpace(slug))
}
func cloneIdentityTraits(traits map[string]any) map[string]any {
if traits == nil {
return map[string]any{}
}
next := make(map[string]any, len(traits))
for key, value := range traits {
next[key] = cloneIdentityTraitValue(value)
}
return next
}
func cloneIdentityTraitValue(value any) any {
switch typed := value.(type) {
case map[string]any:
next := make(map[string]any, len(typed))
for key, nested := range typed {
next[key] = cloneIdentityTraitValue(nested)
}
return next
case []any:
next := make([]any, len(typed))
for i, nested := range typed {
next[i] = cloneIdentityTraitValue(nested)
}
return next
case []map[string]any:
next := make([]any, len(typed))
for i, nested := range typed {
next[i] = cloneIdentityTraitValue(nested)
}
return next
default:
return value
}
}
func (h *TenantHandler) storePromotedIdentityMirror(identity service.KratosIdentity) {
if h == nil || h.IdentityCache == nil || strings.TrimSpace(identity.ID) == "" {
return
}
raw, err := json.Marshal(identity)
if err != nil {
return
}
_ = h.IdentityCache.Set(identityMirrorKey(identity.ID), string(raw), 0)
_ = h.IdentityCache.Delete("identity:mirror:state")
}
func (h *TenantHandler) deletedTenantIDsReferencedByUsers(ctx context.Context, tenantIDs []string) ([]string, error) {
referenced := make(map[string]bool)
var userTenantIDs []string
if err := h.DB.WithContext(ctx).
Model(&domain.User{}).
Where("tenant_id IN ?", tenantIDs).
Distinct("tenant_id").
Pluck("tenant_id", &userTenantIDs).Error; err != nil {
return nil, err
}
for _, tenantID := range userTenantIDs {
if tenantID != "" {
referenced[tenantID] = true
}
}
var loginTenantIDs []string
if err := h.DB.WithContext(ctx).
Model(&domain.UserLoginID{}).
Where("tenant_id IN ?", tenantIDs).
Distinct("tenant_id").
Pluck("tenant_id", &loginTenantIDs).Error; err != nil {
return nil, err
}
for _, tenantID := range loginTenantIDs {
if tenantID != "" {
referenced[tenantID] = true
}
}
result := make([]string, 0, len(referenced))
for _, tenantID := range tenantIDs {
if referenced[tenantID] {
result = append(result, tenantID)
}
}
return result, nil
}
func resolveTenantDeletionPromotionTargets(tenants []domain.Tenant, deletedTenantIDs []string, affectedTenantIDs []string) (map[string]string, error) {
deleted := make(map[string]bool, len(deletedTenantIDs))
for _, tenantID := range deletedTenantIDs {
tenantID = strings.TrimSpace(tenantID)
if tenantID != "" {
deleted[tenantID] = true
}
}
tenantByID := make(map[string]domain.Tenant, len(tenants))
for _, tenant := range tenants {
if strings.TrimSpace(tenant.ID) != "" {
tenantByID[tenant.ID] = tenant
}
}
targets := make(map[string]string, len(affectedTenantIDs))
for _, affectedID := range uniqueNonEmptyStrings(affectedTenantIDs) {
tenant, ok := tenantByID[affectedID]
if !ok {
return nil, fmt.Errorf("tenant %s not found for membership reassignment", affectedID)
}
visited := map[string]bool{affectedID: true}
for {
if tenant.ParentID == nil || strings.TrimSpace(*tenant.ParentID) == "" || *tenant.ParentID == tenant.ID {
return nil, fmt.Errorf("tenant %s cannot be deleted while referenced by users because it has no remaining parent tenant", affectedID)
}
parentID := strings.TrimSpace(*tenant.ParentID)
if visited[parentID] {
return nil, fmt.Errorf("tenant %s cannot be reassigned because its parent chain has a cycle", affectedID)
}
visited[parentID] = true
parent, ok := tenantByID[parentID]
if !ok {
return nil, fmt.Errorf("tenant %s cannot be reassigned because parent tenant %s was not found", affectedID, parentID)
}
if !deleted[parentID] && !parent.DeletedAt.Valid {
targets[affectedID] = parent.ID
break
}
tenant = parent
}
}
return targets, nil
}
func mapTenantSummary(t domain.Tenant) tenantSummary {
domains := make([]string, 0, len(t.Domains))
for _, d := range t.Domains {
@@ -2673,7 +3076,7 @@ func buildOrgContextTree(rootID string, tenants []domain.Tenant, tenantByID map[
return build(rootID)
}
func (h *TenantHandler) countTenantMembersFromProjection(ctx context.Context, tenants []domain.Tenant) (map[string]int64, map[string]int64, error) {
func (h *TenantHandler) countTenantMembers(ctx context.Context, tenants []domain.Tenant) (map[string]int64, map[string]int64, error) {
counts := make(map[string]int64, len(tenants))
for _, tenant := range tenants {
counts[tenant.ID] = 0
@@ -2681,27 +3084,78 @@ func (h *TenantHandler) countTenantMembersFromProjection(ctx context.Context, te
if len(tenants) == 0 {
return counts, counts, nil
}
if h.UserProjectionRepo == nil {
return nil, nil, errors.New("user projection is not configured")
if h.UserRepo == nil {
return counts, counts, nil
}
ready, err := h.UserProjectionRepo.IsReady(ctx)
if err != nil {
return nil, nil, fmt.Errorf("user projection status unavailable: %w", err)
tenantIDs := make([]string, 0, len(tenants))
for _, tenant := range tenants {
if strings.TrimSpace(tenant.ID) != "" {
tenantIDs = append(tenantIDs, tenant.ID)
}
}
if !ready {
return nil, nil, errors.New("user projection is not ready")
}
directCounts, err := h.UserProjectionRepo.CountTenantMembers(ctx, tenants)
directCounts, err := h.UserRepo.CountByTenantIDs(ctx, tenantIDs)
if err != nil {
return nil, nil, err
}
totalCounts, err := h.UserProjectionRepo.CountTenantMembersRecursive(ctx, tenants)
if err != nil {
return nil, nil, err
totalCounts := make(map[string]int64, len(tenants))
allTenants := tenants
if h.Service != nil {
if listed, _, listErr := h.Service.ListTenants(ctx, 10000, 0, "", ""); listErr == nil && len(listed) > 0 {
allTenants = listed
}
}
childrenByParentID := make(map[string][]domain.Tenant)
for _, tenant := range allTenants {
if tenant.ParentID == nil || strings.TrimSpace(*tenant.ParentID) == "" {
continue
}
childrenByParentID[*tenant.ParentID] = append(childrenByParentID[*tenant.ParentID], tenant)
}
for _, tenant := range tenants {
descendantIDs := collectTenantSubtreeIDs(tenant.ID, childrenByParentID)
if len(descendantIDs) == 0 {
totalCounts[tenant.ID] = directCounts[tenant.ID]
continue
}
_, total, _, countErr := h.UserRepo.List(ctx, 0, 1, "", descendantIDs, "")
if countErr != nil {
return nil, nil, countErr
}
totalCounts[tenant.ID] = total
}
return directCounts, totalCounts, nil
}
func collectTenantSubtreeIDs(rootID string, childrenByParentID map[string][]domain.Tenant) []string {
rootID = strings.TrimSpace(rootID)
if rootID == "" {
return nil
}
ids := []string{rootID}
queue := []string{rootID}
seen := map[string]struct{}{rootID: {}}
for len(queue) > 0 {
current := queue[0]
queue = queue[1:]
for _, child := range childrenByParentID[current] {
childID := strings.TrimSpace(child.ID)
if childID == "" {
continue
}
if _, ok := seen[childID]; ok {
continue
}
seen[childID] = struct{}{}
ids = append(ids, childID)
queue = append(queue, childID)
}
}
return ids
}
func normalizeTenantStatus(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
if value == "" {
@@ -2763,7 +3217,6 @@ func (h *TenantHandler) GetOrgChartSnapshot(c *fiber.Ctx) error {
profile, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
cacheMode := strings.ToLower(strings.TrimSpace(c.Query("cache")))
cacheKey := orgChartSnapshotCacheKey(profile, c.Get("X-Tenant-ID"))
ttl := orgChartSnapshotCacheTTL()
role, userID, profileTenantID := orgChartProfileLogValues(profile)
slog.Info("orgchart snapshot request started",
"user_id", userID,
@@ -2778,9 +3231,8 @@ func (h *TenantHandler) GetOrgChartSnapshot(c *fiber.Ctx) error {
var cached orgChartSnapshotResponse
if err := json.Unmarshal([]byte(raw), &cached); err == nil {
cached.Cache = orgChartSnapshotCacheInfo{
Source: "redis",
Hit: true,
TTLSeconds: int(ttl.Seconds()),
Source: "redis",
Hit: true,
}
c.Set("X-Orgfront-Cache", "HIT")
slog.Info("orgchart snapshot cache hit",
@@ -2823,14 +3275,13 @@ func (h *TenantHandler) GetOrgChartSnapshot(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusServiceUnavailable, err.Error())
}
snapshot.Cache = orgChartSnapshotCacheInfo{
Source: "database",
Hit: false,
TTLSeconds: int(ttl.Seconds()),
Source: "database",
Hit: false,
}
if cacheMode == "redis" && h.OrgChartCache != nil {
if raw, err := json.Marshal(snapshot); err == nil {
if err := h.OrgChartCache.Set(cacheKey, string(raw), ttl); err != nil {
if err := h.OrgChartCache.Set(cacheKey, string(raw), orgChartSnapshotCacheExpiration()); err != nil {
slog.Warn("orgchart snapshot cache write failed",
"user_id", userID,
"role", role,
@@ -2858,13 +3309,68 @@ func (h *TenantHandler) GetOrgChartSnapshot(c *fiber.Ctx) error {
return c.JSON(snapshot)
}
func (h *TenantHandler) WarmOrgChartSnapshotCache(ctx context.Context) error {
if h == nil || h.OrgChartCache == nil {
return nil
}
profile := &domain.UserProfileResponse{
ID: "orgfront-cache-warmup",
Role: domain.RoleSuperAdmin,
}
snapshot, err := h.buildOrgChartSnapshot(ctx, profile)
if err != nil {
return err
}
snapshot.Cache = orgChartSnapshotCacheInfo{
Source: "database",
Hit: false,
}
raw, err := json.Marshal(snapshot)
if err != nil {
return err
}
return h.OrgChartCache.Set(
orgChartSnapshotCacheKey(profile, ""),
string(raw),
orgChartSnapshotCacheExpiration(),
)
}
func (h *TenantHandler) refreshOrgChartSnapshotCacheAfterTenantChange(ctx context.Context, reason string) {
if h == nil || h.OrgChartCache == nil {
return
}
deleted, err := h.OrgChartCache.DeleteByPrefix(ctx, orgChartSnapshotCacheKeyPrefix)
if err != nil {
slog.Warn("Orgfront orgchart snapshot cache invalidation failed after tenant change",
"reason", reason,
"prefix", orgChartSnapshotCacheKeyPrefix,
"error", err,
)
return
}
if err := h.WarmOrgChartSnapshotCache(ctx); err != nil {
slog.Warn("Orgfront orgchart snapshot cache refresh failed after tenant change",
"reason", reason,
"error", err,
)
return
}
slog.Info("Orgfront orgchart snapshot cache refreshed after tenant change",
"reason", reason,
"invalidated_keys", deleted,
)
}
func (h *TenantHandler) buildOrgChartSnapshot(ctx context.Context, profile *domain.UserProfileResponse) (orgChartSnapshotResponse, error) {
tenants, err := h.listOrgChartTenantsForProfile(ctx, profile)
if err != nil {
return orgChartSnapshotResponse{}, err
}
memberCounts, totalMemberCounts, err := h.countTenantMembersFromProjection(ctx, tenants)
memberCounts, totalMemberCounts, err := h.countTenantMembers(ctx, tenants)
if err != nil {
return orgChartSnapshotResponse{}, err
}
@@ -3002,7 +3508,10 @@ func orgChartSnapshotCacheKey(profile *domain.UserProfileResponse, tenantHeader
if profile != nil {
role = domain.NormalizeRole(profile.Role)
userID = strings.TrimSpace(profile.ID)
if tenantID == "" && profile.TenantID != nil {
if role == domain.RoleSuperAdmin {
userID = "all"
tenantID = "none"
} else if tenantID == "" && profile.TenantID != nil {
tenantID = strings.TrimSpace(*profile.TenantID)
}
}
@@ -3012,7 +3521,7 @@ func orgChartSnapshotCacheKey(profile *domain.UserProfileResponse, tenantHeader
if tenantID == "" {
tenantID = "none"
}
return fmt.Sprintf("orgchart:snapshot:v1:%s:%s:%s", role, userID, tenantID)
return fmt.Sprintf("%s%s:%s:%s", orgChartSnapshotCacheKeyPrefix, role, userID, tenantID)
}
func orgChartProfileLogValues(profile *domain.UserProfileResponse) (string, string, string) {
@@ -3045,17 +3554,8 @@ func findTenantRootID(parentMap map[string]string, tenantID string) string {
}
}
func orgChartSnapshotCacheTTL() time.Duration {
const defaultTTL = 5 * time.Minute
raw := strings.TrimSpace(os.Getenv("ORGFRONT_ORGCHART_CACHE_TTL_SECONDS"))
if raw == "" {
return defaultTTL
}
seconds, err := strconv.Atoi(raw)
if err != nil || seconds <= 0 {
return defaultTTL
}
return time.Duration(seconds) * time.Second
func orgChartSnapshotCacheExpiration() time.Duration {
return 0
}
func (h *TenantHandler) GetPublicOrgChart(c *fiber.Ctx) error {