forked from baron/baron-sso
chore: snapshot local state before dev merge
This commit is contained in:
@@ -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 == "" {
|
||||
|
||||
Reference in New Issue
Block a user