1
0
forked from baron/baron-sso

chore: snapshot local state before dev merge

This commit is contained in:
2026-06-17 21:25:42 +09:00
parent b2808759d2
commit 49560e8a8c
107 changed files with 8958 additions and 939 deletions

View File

@@ -9,6 +9,7 @@ import (
"baron-sso-backend/internal/utils"
"bytes"
"context"
"encoding/base64"
"encoding/csv"
"encoding/json"
"errors"
@@ -66,6 +67,16 @@ func seedTenantSlugsForDeleteGuard() []string {
return result
}
func tenantCreatorIDForMembership(profile *domain.UserProfileResponse) string {
if profile == nil {
return ""
}
if domain.NormalizeRole(profile.Role) == domain.RoleSuperAdmin {
return ""
}
return strings.TrimSpace(profile.ID)
}
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,
@@ -125,6 +136,20 @@ type tenantListResponse struct {
NextCursor string `json:"nextCursor,omitempty"`
}
type tenantSortDirection string
const (
tenantSortAsc tenantSortDirection = "asc"
tenantSortDesc tenantSortDirection = "desc"
)
type tenantQueryCursor struct {
Sort string `json:"sort"`
Direction string `json:"direction"`
Value string `json:"value"`
ID string `json:"id"`
}
type orgChartSnapshotCacheInfo struct {
Source string `json:"source"`
Hit bool `json:"hit"`
@@ -132,9 +157,10 @@ type orgChartSnapshotCacheInfo struct {
}
type orgChartSnapshotResponse struct {
Tenants []tenantSummary `json:"tenants"`
Users []userSummary `json:"users"`
Cache orgChartSnapshotCacheInfo `json:"cache"`
Tenants []tenantSummary `json:"tenants"`
Users []userSummary `json:"users"`
GeneratedAt string `json:"generatedAt"`
Cache orgChartSnapshotCacheInfo `json:"cache"`
}
func pageTenantsByCursor(tenants []domain.Tenant, limit int, cursorRaw string) ([]domain.Tenant, string, error) {
@@ -147,6 +173,189 @@ func pageTenantsByCursor(tenants []domain.Tenant, limit int, cursorRaw string) (
})
}
func normalizeTenantSortDirection(raw string) tenantSortDirection {
if strings.EqualFold(strings.TrimSpace(raw), string(tenantSortAsc)) {
return tenantSortAsc
}
return tenantSortDesc
}
func normalizeTenantSortKey(raw string) string {
switch strings.TrimSpace(raw) {
case "id", "name", "slug", "type", "status", "createdAt", "updatedAt":
return strings.TrimSpace(raw)
default:
return "createdAt"
}
}
func encodeTenantQueryCursor(sortKey string, direction tenantSortDirection, value string, id string) string {
if strings.TrimSpace(value) == "" || strings.TrimSpace(id) == "" {
return ""
}
payload, err := json.Marshal(tenantQueryCursor{
Sort: sortKey,
Direction: string(direction),
Value: value,
ID: id,
})
if err != nil {
return ""
}
return base64.RawURLEncoding.EncodeToString(payload)
}
func decodeTenantQueryCursor(raw string, sortKey string, direction tenantSortDirection) (*tenantQueryCursor, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
decoded, err := base64.RawURLEncoding.DecodeString(raw)
if err != nil {
return nil, err
}
var cursor tenantQueryCursor
if err := json.Unmarshal(decoded, &cursor); err != nil {
return nil, err
}
if strings.TrimSpace(cursor.ID) == "" || strings.TrimSpace(cursor.Value) == "" {
return nil, errors.New("invalid tenant cursor")
}
if cursor.Sort != sortKey || cursor.Direction != string(direction) {
return nil, errors.New("tenant cursor does not match sort")
}
return &cursor, nil
}
func tenantCursorValue(tenant domain.Tenant, sortKey string) string {
switch sortKey {
case "id":
return tenant.ID
case "name":
return strings.ToLower(tenant.Name)
case "slug":
return strings.ToLower(tenant.Slug)
case "type":
return strings.ToLower(tenant.Type)
case "status":
return strings.ToLower(tenant.Status)
case "updatedAt":
return tenant.UpdatedAt.UTC().Format(time.RFC3339Nano)
default:
return tenant.CreatedAt.UTC().Format(time.RFC3339Nano)
}
}
func filterTenantsByListSearch(tenants []domain.Tenant, search string) []domain.Tenant {
search = strings.ToLower(strings.TrimSpace(search))
if search == "" {
return tenants
}
filtered := make([]domain.Tenant, 0, len(tenants))
for _, tenant := range tenants {
if strings.Contains(strings.ToLower(tenant.ID), search) ||
strings.Contains(strings.ToLower(tenant.Name), search) ||
strings.Contains(strings.ToLower(tenant.Slug), search) ||
strings.Contains(strings.ToLower(tenant.Type), search) ||
strings.Contains(strings.ToLower(tenant.Description), search) {
filtered = append(filtered, tenant)
}
}
return filtered
}
func sortTenantsForList(tenants []domain.Tenant, sortKey string, direction tenantSortDirection) {
sort.SliceStable(tenants, func(i, j int) bool {
left := tenantCursorValue(tenants[i], sortKey)
right := tenantCursorValue(tenants[j], sortKey)
if left == right {
if direction == tenantSortAsc {
return tenants[i].ID < tenants[j].ID
}
return tenants[i].ID > tenants[j].ID
}
if direction == tenantSortAsc {
return left < right
}
return left > right
})
}
func pageSortedTenantsByCursor(tenants []domain.Tenant, limit int, cursorRaw string, sortKey string, direction tenantSortDirection) ([]domain.Tenant, string, error) {
ordered := append([]domain.Tenant(nil), tenants...)
sortTenantsForList(ordered, sortKey, direction)
cursor, err := decodeTenantQueryCursor(cursorRaw, sortKey, direction)
if err != nil {
return nil, "", err
}
if cursor != nil {
filtered := ordered[:0]
for _, tenant := range ordered {
value := tenantCursorValue(tenant, sortKey)
after := value > cursor.Value || (value == cursor.Value && tenant.ID > cursor.ID)
if direction == tenantSortDesc {
after = value < cursor.Value || (value == cursor.Value && tenant.ID < cursor.ID)
}
if after {
filtered = append(filtered, tenant)
}
}
ordered = filtered
}
if len(ordered) <= limit {
return ordered, "", nil
}
page := ordered[:limit]
last := page[len(page)-1]
return page, encodeTenantQueryCursor(sortKey, direction, tenantCursorValue(last, sortKey), last.ID), nil
}
func tenantSortExpression(sortKey string) string {
switch sortKey {
case "id":
return "id::text"
case "name":
return "LOWER(name)"
case "slug":
return "LOWER(slug)"
case "type":
return "LOWER(type)"
case "status":
return "LOWER(status)"
case "updatedAt":
return "updated_at"
default:
return "created_at"
}
}
func tenantOrderClause(sortExpression string, direction tenantSortDirection) string {
if direction == tenantSortAsc {
return sortExpression + " asc, id asc"
}
return sortExpression + " desc, id desc"
}
func applyTenantQueryCursor(db *gorm.DB, sortExpression string, cursor *tenantQueryCursor, direction tenantSortDirection) *gorm.DB {
if cursor == nil {
return db
}
operator := "<"
idOperator := "<"
if direction == tenantSortAsc {
operator = ">"
idOperator = ">"
}
return db.Where(
fmt.Sprintf("%s %s ? OR (%s = ? AND id::text %s ?)", sortExpression, operator, sortExpression, idOperator),
cursor.Value,
cursor.Value,
cursor.ID,
)
}
type tenantImportDetail struct {
Row int `json:"row"`
Slug string `json:"slug"`
@@ -289,6 +498,9 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
offset := c.QueryInt("offset", 0)
parentId := c.Query("parentId")
cursorRaw := strings.TrimSpace(c.Query("cursor"))
search := strings.TrimSpace(c.Query("search"))
sortKey := normalizeTenantSortKey(c.Query("sort"))
sortDirection := normalizeTenantSortDirection(c.Query("direction"))
if limit <= 0 {
limit = 50
@@ -298,6 +510,7 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
}
var tenants []domain.Tenant
var countSourceTenants []domain.Tenant
var total int64
var err error
nextCursor := ""
@@ -365,44 +578,55 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusServiceUnavailable, err.Error())
}
tenants = filterTenantsByListSearch(tenants, search)
countSourceTenants = append([]domain.Tenant(nil), tenants...)
total = int64(len(tenants))
if cursorRaw != "" {
tenants, nextCursor, err = pageTenantsByCursor(tenants, limit, cursorRaw)
tenants, nextCursor, err = pageSortedTenantsByCursor(tenants, limit, cursorRaw, sortKey, sortDirection)
if err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid cursor")
}
offset = 0
} else if offset < len(tenants) {
sortTenantsForList(tenants, sortKey, sortDirection)
end := min(offset+limit, len(tenants))
tenants = tenants[offset:end]
if total > int64(end) && len(tenants) > 0 {
last := tenants[len(tenants)-1]
nextCursor = pagination.Encode(last.CreatedAt, last.ID)
nextCursor = encodeTenantQueryCursor(sortKey, sortDirection, tenantCursorValue(last, sortKey), last.ID)
}
} else {
tenants = []domain.Tenant{}
}
} else {
// Super Admin case
if cursorRaw != "" && h.DB != nil {
tenants, total, nextCursor, err = h.listTenantsByCursor(c.Context(), limit, parentId, cursorRaw, "")
if h.DB != nil {
tenants, total, nextCursor, err = h.listTenantsByCursor(c.Context(), limit, offset, parentId, cursorRaw, search, sortKey, sortDirection)
if err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid cursor")
}
offset = 0
if cursorRaw != "" {
offset = 0
}
} else {
tenants, total, err = h.Service.ListTenants(c.Context(), limit, offset, parentId, "")
tenants, total, err = h.Service.ListTenants(c.Context(), limit, offset, parentId, search)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
if total > int64(offset+len(tenants)) && len(tenants) > 0 {
last := tenants[len(tenants)-1]
nextCursor = pagination.Encode(last.CreatedAt, last.ID)
nextCursor = encodeTenantQueryCursor(sortKey, sortDirection, tenantCursorValue(last, sortKey), last.ID)
}
}
}
memberCounts, totalMemberCounts, err := h.countTenantMembers(c.Context(), tenants)
var memberCounts map[string]int64
var totalMemberCounts map[string]int64
if countSourceTenants != nil {
memberCounts, totalMemberCounts, err = h.countTenantMembersFromTenantSet(c.Context(), tenants, countSourceTenants)
} else {
memberCounts, totalMemberCounts, err = h.countTenantMembers(c.Context(), tenants)
}
if err != nil {
return errorJSON(c, fiber.StatusServiceUnavailable, err.Error())
}
@@ -425,8 +649,8 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
})
}
func (h *TenantHandler) listTenantsByCursor(ctx context.Context, limit int, parentID string, cursorRaw string, search string) ([]domain.Tenant, int64, string, error) {
cursor, err := pagination.Decode(cursorRaw)
func (h *TenantHandler) listTenantsByCursor(ctx context.Context, limit int, offset int, parentID string, cursorRaw string, search string, sortKey string, direction tenantSortDirection) ([]domain.Tenant, int64, string, error) {
cursor, err := decodeTenantQueryCursor(cursorRaw, sortKey, direction)
if err != nil {
return nil, 0, "", err
}
@@ -440,8 +664,9 @@ func (h *TenantHandler) listTenantsByCursor(ctx context.Context, limit int, pare
if search != "" {
searchTerm := "%" + strings.ToLower(search) + "%"
countQuery = countQuery.Where("LOWER(name) LIKE ? OR LOWER(slug) LIKE ? OR LOWER(description) LIKE ?", searchTerm, searchTerm, searchTerm)
pageQuery = pageQuery.Where("LOWER(name) LIKE ? OR LOWER(slug) LIKE ? OR LOWER(description) LIKE ?", searchTerm, searchTerm, searchTerm)
searchClause := "LOWER(id::text) LIKE ? OR LOWER(name) LIKE ? OR LOWER(slug) LIKE ? OR LOWER(type) LIKE ? OR LOWER(description) LIKE ?"
countQuery = countQuery.Where(searchClause, searchTerm, searchTerm, searchTerm, searchTerm, searchTerm)
pageQuery = pageQuery.Where(searchClause, searchTerm, searchTerm, searchTerm, searchTerm, searchTerm)
}
var total int64
@@ -449,11 +674,16 @@ func (h *TenantHandler) listTenantsByCursor(ctx context.Context, limit int, pare
return nil, 0, "", err
}
pageQuery = pagination.ApplyCreatedAtIDCursor(pageQuery, cursor, "created_at", "id")
sortExpression := tenantSortExpression(sortKey)
if cursor != nil {
pageQuery = applyTenantQueryCursor(pageQuery, sortExpression, cursor, direction)
} else if offset > 0 {
pageQuery = pageQuery.Offset(offset)
}
var tenants []domain.Tenant
if err := pageQuery.
Order("created_at desc, id desc").
Order(tenantOrderClause(sortExpression, direction)).
Limit(limit + 1).
Preload("Domains").
Find(&tenants).Error; err != nil {
@@ -464,7 +694,7 @@ func (h *TenantHandler) listTenantsByCursor(ctx context.Context, limit int, pare
if len(tenants) > limit {
tenants = tenants[:limit]
last := tenants[len(tenants)-1]
nextCursor = pagination.Encode(last.CreatedAt, last.ID)
nextCursor = encodeTenantQueryCursor(sortKey, direction, tenantCursorValue(last, sortKey), last.ID)
}
return tenants, total, nextCursor, nil
}
@@ -609,8 +839,8 @@ func (h *TenantHandler) ImportTenantsCSV(c *fiber.Ctx) error {
records = orderTenantCSVRecordsByParentSlug(records)
creatorID := ""
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil {
creatorID = profile.ID
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok {
creatorID = tenantCreatorIDForMembership(profile)
}
tenantIDBySlug := make(map[string]string)
@@ -1861,10 +2091,9 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error {
parentID = &pid
}
// Extract creator ID if present
creatorID := ""
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok {
creatorID = profile.ID
creatorID = tenantCreatorIDForMembership(profile)
}
normalizedDomains := normalizeTenantDomainInputs(req.Domains)
@@ -2887,9 +3116,6 @@ func (h *TenantHandler) GetOrgContext(c *fiber.Ctx) error {
includeUserIDs := strings.EqualFold(strings.TrimSpace(c.Query("includeUserIds")), "true")
membersByTenantID := make(map[string][]orgContextMember)
if includeUsers {
if h.UserRepo == nil {
return errorJSON(c, fiber.StatusServiceUnavailable, "user repository is not configured")
}
membersByTenantID, err = h.loadOrgContextMembers(c.Context(), tenantIDs, tenantSlugs, tenantByID, tenantBySlug, includeUserIDs)
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
@@ -2919,32 +3145,22 @@ func (h *TenantHandler) GetOrgContext(c *fiber.Ctx) error {
}
func (h *TenantHandler) loadOrgContextMembers(ctx context.Context, tenantIDs, tenantSlugs []string, tenantByID, tenantBySlug map[string]orgContextTenant, includeUserIDs bool) (map[string][]orgContextMember, error) {
usersByID, err := h.UserRepo.FindByTenantIDs(ctx, tenantIDs)
if err != nil {
return nil, err
}
usersBySlug, err := h.UserRepo.FindByCompanyCodes(ctx, tenantSlugs)
if err != nil {
return nil, err
}
usersByAppointment, _, _, err := h.UserRepo.List(ctx, 0, 10000, "", []string{}, "")
identities, err := h.listOrgContextIdentities(ctx, tenantIDs, tenantSlugs)
if err != nil {
return nil, err
}
seen := make(map[string]bool)
membersByTenantID := make(map[string][]orgContextMember)
users := append(usersByID, usersBySlug...)
users = append(users, usersByAppointment...)
for _, user := range users {
if seen[user.ID] || !domain.IsOrgVisibleUserStatus(user.Status) {
for _, identity := range identities {
if seen[identity.ID] || !domain.IsOrgVisibleUserStatus(normalizeStatus(identity.State)) {
continue
}
assignments := mapOrgContextMemberAssignments(user, tenantByID, tenantBySlug, includeUserIDs)
assignments := mapOrgContextIdentityAssignments(identity, tenantByID, tenantBySlug, includeUserIDs)
if len(assignments) == 0 {
continue
}
seen[user.ID] = true
seen[identity.ID] = true
for _, assignment := range assignments {
membersByTenantID[assignment.TenantID] = append(membersByTenantID[assignment.TenantID], assignment.Member)
}
@@ -2952,6 +3168,55 @@ func (h *TenantHandler) loadOrgContextMembers(ctx context.Context, tenantIDs, te
return membersByTenantID, nil
}
func (h *TenantHandler) listOrgContextIdentities(ctx context.Context, tenantIDs, tenantSlugs []string) ([]service.KratosIdentity, error) {
allowedTenantKeys := make(map[string]bool, len(tenantIDs)+len(tenantSlugs))
for _, value := range append(tenantIDs, tenantSlugs...) {
key := strings.ToLower(strings.TrimSpace(value))
if key != "" {
allowedTenantKeys[key] = true
}
}
query := service.IdentityMirrorPageQuery{
Limit: 10000,
AllowedTenantKeys: allowedTenantKeys,
}
if h != nil && h.IdentityCache != nil {
if lister, ok := h.IdentityCache.(identityMirrorPageLister); ok && h.orgContextIdentityMirrorReady(ctx) {
result, err := lister.ListIdentityMirrorPage(ctx, query)
if err != nil {
return nil, err
}
return result.Items, nil
}
}
if h == nil || h.KratosAdmin == nil {
return nil, errors.New("identity mirror is unavailable")
}
identities, err := h.KratosAdmin.ListIdentities(ctx)
if err != nil {
return nil, err
}
result, err := pageIdentityMirrorSlice(identities, query)
if err != nil {
return nil, err
}
return result.Items, nil
}
func (h *TenantHandler) orgContextIdentityMirrorReady(ctx context.Context) bool {
reader, ok := h.IdentityCache.(identityMirrorStatusReader)
if !ok {
return false
}
status, err := reader.GetIdentityCacheStatus(ctx)
if err != nil {
return false
}
return status.RedisReady &&
status.Status == "ready" &&
status.MirrorVersion == identityMirrorVersion
}
func findOrgContextTenantBySlug(tenants []domain.Tenant, slug string) (domain.Tenant, bool) {
normalized := strings.ToLower(strings.TrimSpace(slug))
for _, tenant := range tenants {
@@ -3086,8 +3351,55 @@ func mapOrgContextMemberAssignments(user domain.User, tenantByID, tenantBySlug m
return assignments
}
func mapOrgContextIdentityAssignments(identity service.KratosIdentity, tenantByID, tenantBySlug map[string]orgContextTenant, includeUserIDs bool) []orgContextMemberAssignment {
assignments := make([]orgContextMemberAssignment, 0, 2)
seenTenants := map[string]bool{}
traits := identity.Traits
appointments := tenantClaimAppointmentsFromTraits(traits)
addTenant := func(tenant orgContextTenant, ok bool, appointment map[string]any) {
if !ok || seenTenants[tenant.ID] {
return
}
seenTenants[tenant.ID] = true
if appointment == nil {
appointment = lookupTenantClaimAppointment(appointments, tenant.ID, &domain.Tenant{
ID: tenant.ID,
Slug: tenant.Slug,
})
}
assignments = append(assignments, orgContextMemberAssignment{
TenantID: tenant.ID,
Member: mapOrgContextIdentityMember(identity, appointment, includeUserIDs),
})
}
for _, appointment := range appointments {
for _, key := range []string{"tenantId", "tenant_id"} {
if tenantID := tenantClaimString(appointment, key); tenantID != "" {
addTenant(tenantByID[tenantID], tenantByID[tenantID].ID != "", appointment)
}
}
for _, key := range []string{"tenantSlug", "tenant_slug", "slug"} {
if tenantSlug := tenantClaimString(appointment, key); tenantSlug != "" {
tenant := tenantBySlug[strings.ToLower(tenantSlug)]
addTenant(tenant, tenant.ID != "", appointment)
}
}
}
if tenantID := extractTraitString(traits, "tenant_id"); tenantID != "" {
addTenant(tenantByID[tenantID], tenantByID[tenantID].ID != "", nil)
}
if tenantSlug := extractTraitString(traits, "tenantSlug"); tenantSlug != "" {
tenant := tenantBySlug[strings.ToLower(tenantSlug)]
addTenant(tenant, tenant.ID != "", nil)
}
return assignments
}
func mapOrgContextMember(user domain.User, appointment map[string]any, includeUserIDs bool) orgContextMember {
grade := user.Grade
grade := ""
position := user.Position
jobTitle := user.JobTitle
department := user.Department
@@ -3139,6 +3451,60 @@ func mapOrgContextMember(user domain.User, appointment map[string]any, includeUs
}
}
func mapOrgContextIdentityMember(identity service.KratosIdentity, appointment map[string]any, includeUserIDs bool) orgContextMember {
traits := identity.Traits
grade := ""
position := extractTraitString(traits, "position")
jobTitle := extractTraitString(traits, "jobTitle")
department := extractTraitString(traits, "department")
if value := tenantClaimString(appointment, "grade"); value != "" {
grade = value
}
if value := tenantClaimString(appointment, "position"); value != "" {
position = value
}
if value := tenantClaimString(appointment, "jobTitle"); value != "" {
jobTitle = value
}
if value := tenantClaimString(appointment, "job_title"); value != "" {
jobTitle = value
}
if value := tenantClaimString(appointment, "department"); value != "" {
department = value
}
isOwner := false
if value, ok := metadataBoolFromMap(appointment, "isOwner"); ok {
isOwner = value
}
isManager := false
if value, ok := metadataBoolFromMap(appointment, "isManager", "lead", "isLead"); ok {
isManager = value
}
isPrimary := false
if value, ok := metadataBoolFromMap(appointment, "representative", "isPrimary", "primary"); ok {
isPrimary = value
}
id := ""
phone := ""
if includeUserIDs {
id = identity.ID
phone = extractTraitString(traits, "phone_number")
}
return orgContextMember{
ID: id,
Email: extractTraitString(traits, "email"),
Name: extractTraitString(traits, "name"),
Phone: phone,
Department: department,
Grade: grade,
Position: position,
JobTitle: jobTitle,
IsOwner: isOwner,
IsManager: isManager,
IsPrimary: isPrimary,
}
}
func buildOrgContextTree(rootID string, tenants []domain.Tenant, tenantByID map[string]orgContextTenant) *orgContextTreeNode {
childrenByParentID := make(map[string][]domain.Tenant)
for _, tenant := range tenants {
@@ -3170,6 +3536,24 @@ func buildOrgContextTree(rootID string, tenants []domain.Tenant, tenantByID map[
}
func (h *TenantHandler) countTenantMembers(ctx context.Context, tenants []domain.Tenant) (map[string]int64, map[string]int64, error) {
allTenants := tenants
if h.DB != nil {
var edges []domain.Tenant
if err := h.DB.WithContext(ctx).
Model(&domain.Tenant{}).
Select("id", "parent_id").
Find(&edges).Error; err == nil && len(edges) > 0 {
allTenants = edges
}
} else if h.Service != nil {
if listed, _, listErr := h.Service.ListTenants(ctx, 10000, 0, "", ""); listErr == nil && len(listed) > 0 {
allTenants = listed
}
}
return h.countTenantMembersFromTenantSet(ctx, tenants, allTenants)
}
func (h *TenantHandler) countTenantMembersFromTenantSet(ctx context.Context, tenants []domain.Tenant, allTenants []domain.Tenant) (map[string]int64, map[string]int64, error) {
counts := make(map[string]int64, len(tenants))
for _, tenant := range tenants {
counts[tenant.ID] = 0
@@ -3181,25 +3565,22 @@ func (h *TenantHandler) countTenantMembers(ctx context.Context, tenants []domain
return counts, counts, nil
}
tenantIDs := make([]string, 0, len(tenants))
for _, tenant := range tenants {
totalCounts := make(map[string]int64, len(tenants))
allTenantIDs := make([]string, 0, len(allTenants))
for _, tenant := range allTenants {
if strings.TrimSpace(tenant.ID) != "" {
tenantIDs = append(tenantIDs, tenant.ID)
allTenantIDs = append(allTenantIDs, tenant.ID)
}
}
directCounts, err := h.UserRepo.CountByTenantIDs(ctx, tenantIDs)
directCounts, err := h.UserRepo.CountByTenantIDs(ctx, allTenantIDs)
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
}
for _, tenant := range tenants {
counts[tenant.ID] = directCounts[tenant.ID]
}
childrenByParentID := make(map[string][]domain.Tenant)
for _, tenant := range allTenants {
if tenant.ParentID == nil || strings.TrimSpace(*tenant.ParentID) == "" {
@@ -3209,13 +3590,9 @@ func (h *TenantHandler) countTenantMembers(ctx context.Context, tenants []domain
}
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
var total int64
for _, descendantID := range descendantIDs {
total += directCounts[descendantID]
}
totalCounts[tenant.ID] = total
}
@@ -3309,6 +3686,7 @@ func (h *TenantHandler) DeleteShareLink(c *fiber.Ctx) error {
func (h *TenantHandler) GetOrgChartSnapshot(c *fiber.Ctx) error {
profile, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
cacheMode := strings.ToLower(strings.TrimSpace(c.Query("cache")))
refreshRequested := parseBoolQuery(c.Query("refresh"))
cacheKey := orgChartSnapshotCacheKey(profile, c.Get("X-Tenant-ID"))
role, userID, profileTenantID := orgChartProfileLogValues(profile)
slog.Info("orgchart snapshot request started",
@@ -3317,12 +3695,16 @@ func (h *TenantHandler) GetOrgChartSnapshot(c *fiber.Ctx) error {
"profile_tenant_id", profileTenantID,
"tenant_header", c.Get("X-Tenant-ID"),
"cache_mode", cacheMode,
"refresh", refreshRequested,
)
if cacheMode == "redis" && h.OrgChartCache != nil {
if cacheMode == "redis" && h.OrgChartCache != nil && !refreshRequested {
if raw, err := h.OrgChartCache.Get(cacheKey); err == nil && strings.TrimSpace(raw) != "" {
var cached orgChartSnapshotResponse
if err := json.Unmarshal([]byte(raw), &cached); err == nil {
if strings.TrimSpace(cached.GeneratedAt) == "" {
cached.GeneratedAt = time.Now().UTC().Format(time.RFC3339)
}
cached.Cache = orgChartSnapshotCacheInfo{
Source: "redis",
Hit: true,
@@ -3384,7 +3766,11 @@ func (h *TenantHandler) GetOrgChartSnapshot(c *fiber.Ctx) error {
)
}
}
c.Set("X-Orgfront-Cache", "MISS")
if refreshRequested {
c.Set("X-Orgfront-Cache", "REFRESH")
} else {
c.Set("X-Orgfront-Cache", "MISS")
}
} else {
c.Set("X-Orgfront-Cache", "BYPASS")
}
@@ -3482,8 +3868,9 @@ func (h *TenantHandler) buildOrgChartSnapshot(ctx context.Context, profile *doma
}
return orgChartSnapshotResponse{
Tenants: tenantSummaries,
Users: users,
Tenants: tenantSummaries,
Users: users,
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
}, nil
}
@@ -3543,57 +3930,133 @@ func (h *TenantHandler) listOrgChartTenantsForProfile(ctx context.Context, profi
}
func (h *TenantHandler) listOrgChartUsers(ctx context.Context, profile *domain.UserProfileResponse, tenants []domain.Tenant) ([]userSummary, error) {
if h.UserRepo == nil {
return nil, errors.New("user repository is not configured")
}
role := ""
if profile != nil {
role = domain.NormalizeRole(profile.Role)
}
tenantIDs := []string{}
tenantSlugs := []string{}
if role != domain.RoleSuperAdmin {
tenantIDs = make([]string, 0, len(tenants))
tenantSlugs = make([]string, 0, len(tenants))
for _, tenant := range tenants {
tenantIDs = append(tenantIDs, tenant.ID)
tenantSlugs = append(tenantSlugs, tenant.Slug)
}
}
users, _, _, err := h.UserRepo.List(ctx, 0, 10000, "", tenantIDs, "")
identities, err := h.listOrgContextIdentities(ctx, tenantIDs, tenantSlugs)
if err != nil {
return nil, err
}
summaries := make([]userSummary, 0, len(users))
for _, user := range users {
summary := userSummary{
ID: user.ID,
Email: user.Email,
LoginID: user.Email,
Name: user.Name,
Phone: user.Phone,
Role: domain.NormalizeRole(user.Role),
Status: normalizeStatus(user.Status),
TenantSlug: userTenantSlug(user),
CompanyCode: userTenantSlug(user),
Metadata: user.Metadata,
Tenant: user.Tenant,
Department: user.Department,
Grade: user.Grade,
Position: user.Position,
JobTitle: user.JobTitle,
CreatedAt: formatTime(user.CreatedAt),
UpdatedAt: formatTime(user.UpdatedAt),
}
if h.Service != nil {
if joined, err := h.Service.ListJoinedTenants(ctx, user.ID); err == nil {
summary.JoinedTenants = joined
}
}
summaries = append(summaries, summary)
tenantByID := make(map[string]domain.Tenant, len(tenants))
tenantBySlug := make(map[string]domain.Tenant, len(tenants))
for _, tenant := range tenants {
tenantByID[tenant.ID] = tenant
tenantBySlug[strings.ToLower(tenant.Slug)] = tenant
}
summaries := make([]userSummary, 0, len(identities))
for _, identity := range identities {
summaries = append(summaries, mapOrgChartIdentitySummary(identity, tenantByID, tenantBySlug))
}
return summaries, nil
}
func mapOrgChartIdentitySummary(identity service.KratosIdentity, tenantByID, tenantBySlug map[string]domain.Tenant) userSummary {
traits := identity.Traits
tenantID := extractTraitString(traits, "tenant_id")
tenantSlug := extractTraitString(traits, "tenantSlug")
var tenantSummary *domain.Tenant
if tenantID != "" {
if tenant, ok := tenantByID[tenantID]; ok {
tenantCopy := tenant
tenantSummary = &tenantCopy
if tenantSlug == "" {
tenantSlug = tenant.Slug
}
}
}
if tenantSummary == nil && tenantSlug != "" {
if tenant, ok := tenantBySlug[strings.ToLower(tenantSlug)]; ok {
tenantCopy := tenant
tenantSummary = &tenantCopy
if tenantID == "" {
tenantID = tenant.ID
}
}
}
metadata := make(domain.JSONMap)
coreTraits := map[string]bool{
"email": true, "name": true, "phone_number": true,
"grade": true, "companyCode": true, "company_code": true, "companyCodes": true, "department": true,
"position": true, "jobTitle": true,
"affiliationType": true, "role": true, "tenant_id": true, "tenantSlug": true,
"custom_login_ids": true, "id": true,
}
for key, value := range traits {
if !coreTraits[key] {
metadata[key] = value
}
}
return userSummary{
ID: identity.ID,
Email: extractTraitString(traits, "email"),
LoginID: resolvePasswordLoginID(traits),
Name: extractTraitString(traits, "name"),
Phone: extractTraitString(traits, "phone_number"),
Role: roleFromTraits(traits),
Status: normalizeStatus(identity.State),
TenantSlug: tenantSlug,
CompanyCode: tenantSlug,
Metadata: metadata,
Tenant: tenantSummary,
JoinedTenants: orgChartIdentityJoinedTenants(traits, tenantByID, tenantBySlug),
Department: extractTraitString(traits, "department"),
Grade: gradeFromTraits(traits),
Position: extractTraitString(traits, "position"),
JobTitle: extractTraitString(traits, "jobTitle"),
CreatedAt: formatTime(identity.CreatedAt),
UpdatedAt: formatTime(identity.UpdatedAt),
}
}
func orgChartIdentityJoinedTenants(traits map[string]any, tenantByID, tenantBySlug map[string]domain.Tenant) []domain.Tenant {
seen := make(map[string]bool)
joined := make([]domain.Tenant, 0, 2)
addTenant := func(tenant domain.Tenant, ok bool) {
if !ok || strings.TrimSpace(tenant.ID) == "" || seen[tenant.ID] {
return
}
seen[tenant.ID] = true
joined = append(joined, tenant)
}
if tenantID := extractTraitString(traits, "tenant_id"); tenantID != "" {
addTenant(tenantByID[tenantID], tenantByID[tenantID].ID != "")
}
if tenantSlug := extractTraitString(traits, "tenantSlug"); tenantSlug != "" {
tenant := tenantBySlug[strings.ToLower(tenantSlug)]
addTenant(tenant, tenant.ID != "")
}
for _, appointment := range tenantClaimAppointmentsFromTraits(traits) {
for _, key := range []string{"tenantId", "tenant_id"} {
if tenantID := tenantClaimString(appointment, key); tenantID != "" {
addTenant(tenantByID[tenantID], tenantByID[tenantID].ID != "")
}
}
for _, key := range []string{"tenantSlug", "tenant_slug", "slug"} {
if tenantSlug := tenantClaimString(appointment, key); tenantSlug != "" {
tenant := tenantBySlug[strings.ToLower(tenantSlug)]
addTenant(tenant, tenant.ID != "")
}
}
}
return joined
}
func orgChartSnapshotCacheKey(profile *domain.UserProfileResponse, tenantHeader string) string {
role := "anonymous"
userID := "anonymous"
@@ -3651,6 +4114,15 @@ func orgChartSnapshotCacheExpiration() time.Duration {
return 0
}
func parseBoolQuery(value string) bool {
switch strings.ToLower(strings.TrimSpace(value)) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
func (h *TenantHandler) GetPublicOrgChart(c *fiber.Ctx) error {
token := c.Query("token")
if token == "" {