1
0
forked from baron/baron-sso
Files
baron-sso/backend/internal/handler/tenant_handler.go
2026-06-10 09:36:57 +09:00

3155 lines
92 KiB
Go

package handler
import (
"baron-sso-backend/internal/bootstrap"
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/pagination"
"baron-sso-backend/internal/repository"
"baron-sso-backend/internal/service"
"baron-sso-backend/internal/utils"
"bytes"
"context"
"encoding/csv"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"maps"
"os"
"reflect"
"sort"
"strconv"
"strings"
"time"
"github.com/go-redis/redis/v8"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
)
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
}
type orgChartCacheStore interface {
Get(key string) (string, error)
Set(key string, value string, expiration time.Duration) error
}
func seedTenantDeleteError(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusConflict, "seed tenants cannot be deleted")
}
func seedTenantSlugsForDeleteGuard() []string {
slugs, err := bootstrap.SeedTenantSlugSet()
if err != nil {
return nil
}
result := make([]string, 0, len(slugs))
for slug := range slugs {
result = append(result, slug)
}
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 {
return &TenantHandler{
DB: db,
Service: svc,
UserRepo: userRepo,
UserProjectionRepo: userProjectionRepo,
Keto: keto,
KetoOutbox: outbox,
KratosAdmin: kratos,
SharedLink: sharedLink,
Hydra: hydra,
ConsentRepo: consentRepo,
}
}
func (h *TenantHandler) SetWorksmobileSyncer(syncer service.WorksmobileSyncer) {
h.Worksmobile = syncer
}
type tenantSummary struct {
ID string `json:"id"`
Type string `json:"type"`
ParentID *string `json:"parentId"`
Name string `json:"name"`
Slug string `json:"slug"`
Description string `json:"description"`
Status string `json:"status"`
Domains []string `json:"domains,omitempty"`
Config domain.JSONMap `json:"config,omitempty"`
MemberCount int64 `json:"memberCount"`
TotalMemberCount int64 `json:"totalMemberCount"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
type tenantListResponse struct {
Items []tenantSummary `json:"items"`
Limit int `json:"limit"`
Offset int `json:"offset"`
Total int64 `json:"total"`
Cursor string `json:"cursor,omitempty"`
NextCursor string `json:"nextCursor,omitempty"`
}
type orgChartSnapshotCacheInfo struct {
Source string `json:"source"`
Hit bool `json:"hit"`
TTLSeconds int `json:"ttlSeconds,omitempty"`
}
type orgChartSnapshotResponse struct {
Tenants []tenantSummary `json:"tenants"`
Users []userSummary `json:"users"`
Cache orgChartSnapshotCacheInfo `json:"cache"`
}
func pageTenantsByCursor(tenants []domain.Tenant, limit int, cursorRaw string) ([]domain.Tenant, string, error) {
ordered := append([]domain.Tenant(nil), tenants...)
pagination.SortByKeyDesc(ordered, func(tenant domain.Tenant) (time.Time, string) {
return tenant.CreatedAt, tenant.ID
})
return pagination.PageByCursor(ordered, limit, cursorRaw, func(tenant domain.Tenant) (time.Time, string) {
return tenant.CreatedAt, tenant.ID
})
}
type tenantImportDetail struct {
Row int `json:"row"`
Slug string `json:"slug"`
Name string `json:"name"`
Success bool `json:"success"`
Action string `json:"action"` // "created", "updated", "failed", "skipped"
Message string `json:"message"` // Detailed error or success message
ModifiedFields []string `json:"modifiedFields"` // List of fields changed during update
}
type tenantImportResult struct {
Created int `json:"created"`
Updated int `json:"updated"`
Failed int `json:"failed"`
Errors []string `json:"errors"`
Details []tenantImportDetail `json:"details"`
}
type tenantDomainConflict struct {
Domain string `json:"domain"`
TenantID string `json:"tenantId"`
TenantName string `json:"tenantName"`
TenantSlug string `json:"tenantSlug"`
}
type tenantCSVRecord struct {
TenantID string
Name string
Type string
ParentTenantID *string
ParentTenantSlug string
Slug string
Memo string
Domains []string
Visibility string
OrgUnitType string
WorksmobileSync string
WorksmobileSyncSet bool
}
type orgContextTenant struct {
ID string `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
Slug string `json:"slug"`
ParentID *string `json:"parentId"`
Status string `json:"status"`
Description string `json:"description"`
Domains []string `json:"domains,omitempty"`
MemberCount int64 `json:"memberCount"`
Visibility string `json:"visibility"`
OrgUnitType string `json:"orgUnitType,omitempty"`
Config domain.JSONMap `json:"config,omitempty"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
Members []orgContextMember `json:"members"`
}
type orgContextMember struct {
ID string `json:"id,omitempty"`
Email string `json:"email"`
Name string `json:"name"`
Phone string `json:"phone,omitempty"`
Department string `json:"department,omitempty"`
Grade string `json:"grade,omitempty"`
Position string `json:"position,omitempty"`
JobTitle string `json:"jobTitle,omitempty"`
IsOwner bool `json:"isOwner"`
IsManager bool `json:"isManager"`
IsPrimary bool `json:"isPrimary"`
}
type orgContextMemberAssignment struct {
TenantID string
Member orgContextMember
}
type orgContextTreeNode struct {
orgContextTenant
Children []orgContextTreeNode `json:"children"`
}
type orgContextScope struct {
TenantID string `json:"tenantId"`
TenantSlug string `json:"tenantSlug"`
}
type orgContextResponse struct {
SchemaVersion string `json:"schemaVersion"`
IssuedAt string `json:"issuedAt"`
Scope orgContextScope `json:"scope"`
Tree *orgContextTreeNode `json:"tree"`
Tenants []orgContextTenant `json:"tenants"`
}
func (h *TenantHandler) RegisterTenantPublic(c *fiber.Ctx) error {
var req struct {
Name string `json:"name"`
Slug string `json:"slug"`
Description string `json:"description"`
Domain string `json:"domain"`
AdminEmail string `json:"adminEmail"`
}
if err := c.BodyParser(&req); err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
}
// Basic validation
if req.Name == "" || req.Domain == "" || req.AdminEmail == "" {
return errorJSON(c, fiber.StatusBadRequest, "name, domain, and adminEmail are required")
}
tenant, err := h.Service.RequestRegistration(c.Context(), req.Name, req.Slug, req.Description, req.Domain, req.AdminEmail)
if err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error())
}
return c.Status(fiber.StatusAccepted).JSON(fiber.Map{
"message": "Registration request received and is pending approval.",
"tenant": mapTenantSummary(*tenant),
})
}
func (h *TenantHandler) ApproveTenant(c *fiber.Ctx) error {
tenantID := c.Params("id")
if tenantID == "" {
return errorJSON(c, fiber.StatusBadRequest, "tenant id is required")
}
if err := h.Service.ApproveTenant(c.Context(), tenantID); err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
return c.JSON(fiber.Map{"message": "Tenant approved successfully"})
}
func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
limit := c.QueryInt("limit", 50)
offset := c.QueryInt("offset", 0)
parentId := c.Query("parentId")
cursorRaw := strings.TrimSpace(c.Query("cursor"))
if limit <= 0 {
limit = 50
}
if offset < 0 {
offset = 0
}
var tenants []domain.Tenant
var total int64
var err error
nextCursor := ""
profile, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
role := ""
if profile != nil {
role = domain.NormalizeRole(profile.Role)
}
if role != domain.RoleSuperAdmin {
// Not a super admin: Only return the entire tree(s) of the tenants they belong to
allTenants, _, err := h.Service.ListTenants(c.Context(), 10000, 0, "", "")
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
if profile != nil {
baseTenantIDs := []string{}
for _, t := range profile.ManageableTenants {
baseTenantIDs = append(baseTenantIDs, t.ID)
}
for _, t := range profile.JoinedTenants {
baseTenantIDs = append(baseTenantIDs, t.ID)
}
if profile.TenantID != nil {
baseTenantIDs = append(baseTenantIDs, *profile.TenantID)
}
parentMap := make(map[string]string)
for _, t := range allTenants {
if t.ParentID != nil {
parentMap[t.ID] = *t.ParentID
}
}
roots := make(map[string]bool)
for _, id := range baseTenantIDs {
roots[findTenantRootID(parentMap, id)] = true
}
// Filter tenants that belong to the same tree family
for _, t := range allTenants {
if roots[findTenantRootID(parentMap, t.ID)] {
tenants = append(tenants, t)
}
}
}
tenants, err = h.filterPrivateTenantsForProfile(c.Context(), tenants, profile)
if err != nil {
return errorJSON(c, fiber.StatusServiceUnavailable, err.Error())
}
total = int64(len(tenants))
if cursorRaw != "" {
tenants, nextCursor, err = pageTenantsByCursor(tenants, limit, cursorRaw)
if err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid cursor")
}
offset = 0
} else if offset < len(tenants) {
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)
}
} 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 err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid cursor")
}
offset = 0
} else {
tenants, total, err = h.Service.ListTenants(c.Context(), limit, offset, parentId, "")
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)
}
}
}
memberCounts, totalMemberCounts, err := h.countTenantMembersFromProjection(c.Context(), tenants)
if err != nil {
return errorJSON(c, fiber.StatusServiceUnavailable, err.Error())
}
items := make([]tenantSummary, 0, len(tenants))
for _, t := range tenants {
summary := mapTenantSummary(t)
summary.MemberCount = memberCounts[t.ID]
summary.TotalMemberCount = totalMemberCounts[t.ID]
items = append(items, summary)
}
return c.JSON(tenantListResponse{
Items: items,
Limit: limit,
Offset: offset,
Total: total,
Cursor: cursorRaw,
NextCursor: nextCursor,
})
}
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)
if err != nil {
return nil, 0, "", err
}
countQuery := h.DB.WithContext(ctx).Model(&domain.Tenant{})
pageQuery := h.DB.WithContext(ctx).Model(&domain.Tenant{})
if parentID != "" {
countQuery = countQuery.Where("parent_id = ?", parentID)
pageQuery = pageQuery.Where("parent_id = ?", parentID)
}
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)
}
var total int64
if err := countQuery.Count(&total).Error; err != nil {
return nil, 0, "", err
}
pageQuery = pagination.ApplyCreatedAtIDCursor(pageQuery, cursor, "created_at", "id")
var tenants []domain.Tenant
if err := pageQuery.
Order("created_at desc, id desc").
Limit(limit + 1).
Preload("Domains").
Find(&tenants).Error; err != nil {
return nil, 0, "", err
}
nextCursor := ""
if len(tenants) > limit {
tenants = tenants[:limit]
last := tenants[len(tenants)-1]
nextCursor = pagination.Encode(last.CreatedAt, last.ID)
}
return tenants, total, nextCursor, nil
}
func (h *TenantHandler) ExportTenantsCSV(c *fiber.Ctx) error {
parentID := strings.TrimSpace(c.Query("parentId"))
allTenants, _, err := h.Service.ListTenants(c.Context(), 10000, 0, "", "")
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
profile, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
allTenants, err = h.filterPrivateTenantsForProfile(c.Context(), allTenants, profile)
if err != nil {
return errorJSON(c, fiber.StatusServiceUnavailable, err.Error())
}
tenants := filterTenantCSVDescendants(allTenants, parentID)
sortTenantsByInputOrder(tenants)
var buf bytes.Buffer
writer := csv.NewWriter(&buf)
includeIDs := includeCSVIds(c)
if includeIDs {
if err := writer.Write([]string{"tenant_id", "name", "type", "parent_tenant_id", "parent_tenant_slug", "slug", "memo", "email_domain", "visibility", "org_unit_type", "worksmobile_sync"}); err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
} else if err := writer.Write([]string{"name", "type", "parent_tenant_slug", "slug", "memo", "email_domain", "visibility", "org_unit_type", "worksmobile_sync"}); err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
slugByID := make(map[string]string, len(allTenants))
for _, tenant := range allTenants {
slugByID[tenant.ID] = tenant.Slug
}
for _, tenant := range tenants {
parentID := ""
parentSlug := ""
if tenant.ParentID != nil {
parentID = *tenant.ParentID
parentSlug = slugByID[parentID]
}
domains := make([]string, 0, len(tenant.Domains))
for _, domainName := range tenant.Domains {
domainName := strings.TrimSpace(domainName.Domain)
if domainName != "" {
domains = append(domains, domainName)
}
}
visibility, orgUnitType, worksmobileSync := tenantCSVOrgConfigValues(tenant.Config)
row := []string{
tenant.Name,
tenant.Type,
parentSlug,
tenant.Slug,
tenant.Description,
strings.Join(domains, ";"),
visibility,
orgUnitType,
worksmobileSync,
}
if includeIDs {
row = []string{
tenant.ID,
tenant.Name,
tenant.Type,
parentID,
parentSlug,
tenant.Slug,
tenant.Description,
strings.Join(domains, ";"),
visibility,
orgUnitType,
worksmobileSync,
}
}
if err := writer.Write(row); err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
}
writer.Flush()
if err := writer.Error(); err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
c.Set(fiber.HeaderContentType, "text/csv")
c.Set(fiber.HeaderContentDisposition, `attachment; filename="tenants.csv"`)
return c.Send(buf.Bytes())
}
func sortTenantsByInputOrder(tenants []domain.Tenant) {
sort.SliceStable(tenants, func(i, j int) bool {
if tenants[i].CreatedAt.Equal(tenants[j].CreatedAt) {
return tenants[i].ID < tenants[j].ID
}
return tenants[i].CreatedAt.Before(tenants[j].CreatedAt)
})
}
func filterTenantCSVDescendants(tenants []domain.Tenant, parentID string) []domain.Tenant {
parentID = strings.TrimSpace(parentID)
if parentID == "" {
return tenants
}
descendantIDs := map[string]bool{}
frontier := map[string]bool{parentID: true}
for len(frontier) > 0 {
next := map[string]bool{}
for _, tenant := range tenants {
if tenant.ParentID == nil {
continue
}
if !frontier[strings.TrimSpace(*tenant.ParentID)] {
continue
}
if descendantIDs[tenant.ID] {
continue
}
descendantIDs[tenant.ID] = true
next[tenant.ID] = true
}
frontier = next
}
filtered := make([]domain.Tenant, 0, len(descendantIDs))
for _, tenant := range tenants {
if descendantIDs[tenant.ID] {
filtered = append(filtered, tenant)
}
}
return filtered
}
func (h *TenantHandler) ImportTenantsCSV(c *fiber.Ctx) error {
reader, err := tenantCSVReaderFromRequest(c)
if err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error())
}
records, err := parseTenantCSVRecords(reader)
if err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error())
}
records = orderTenantCSVRecordsByParentSlug(records)
creatorID := ""
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil {
creatorID = profile.ID
}
tenantIDBySlug := make(map[string]string)
if h.Service != nil {
if tenants, _, err := h.Service.ListTenants(c.Context(), 10000, 0, "", ""); err == nil {
for _, tenant := range tenants {
tenantIDBySlug[strings.ToLower(tenant.Slug)] = tenant.ID
}
}
}
result := tenantImportResult{
Errors: make([]string, 0),
Details: make([]tenantImportDetail, 0, len(records)),
}
for i, record := range records {
rowNumber := i + 2
detail := tenantImportDetail{
Row: rowNumber,
Slug: record.Slug,
Name: record.Name,
}
if record.ParentTenantID == nil && record.ParentTenantSlug != "" {
parentID := tenantIDBySlug[strings.ToLower(record.ParentTenantSlug)]
if parentID == "" {
result.Failed++
msg := fmt.Sprintf("row %d: parent tenant slug not found: %s", rowNumber, record.ParentTenantSlug)
result.Errors = append(result.Errors, msg)
detail.Success = false
detail.Action = "failed"
detail.Message = msg
result.Details = append(result.Details, detail)
continue
}
record.ParentTenantID = &parentID
}
if record.TenantID != "" || (h.DB != nil && record.Slug != "") {
tenant, modifiedFields, err := h.upsertTenantCSVRecord(c, record)
if err != nil {
result.Failed++
msg := fmt.Sprintf("row %d: %s", rowNumber, err.Error())
result.Errors = append(result.Errors, msg)
detail.Success = false
detail.Action = "failed"
detail.Message = msg
result.Details = append(result.Details, detail)
continue
}
if tenant != nil {
if len(modifiedFields) > 0 {
tenantIDBySlug[strings.ToLower(record.Slug)] = tenant.ID
result.Updated++
detail.Success = true
detail.Action = "updated"
detail.ModifiedFields = modifiedFields
if h.Worksmobile != nil {
_ = h.Worksmobile.EnqueueTenantUpsertIfInScope(c.Context(), *tenant)
}
result.Details = append(result.Details, detail)
continue
} else {
// No changes, skip
detail.Success = true
detail.Action = "skipped"
detail.Message = "no changes detected"
result.Details = append(result.Details, detail)
continue
}
}
}
recordCreatorID := creatorID
if record.Type == domain.TenantTypeOrganization {
recordCreatorID = ""
}
tenant, err := h.createTenantCSVRecord(c, record, recordCreatorID)
if err != nil {
result.Failed++
msg := fmt.Sprintf("row %d: %s", rowNumber, err.Error())
result.Errors = append(result.Errors, msg)
detail.Success = false
detail.Action = "failed"
detail.Message = msg
result.Details = append(result.Details, detail)
continue
}
if tenant == nil {
result.Failed++
msg := fmt.Sprintf("row %d: tenant creation returned empty result", rowNumber)
result.Errors = append(result.Errors, msg)
detail.Success = false
detail.Action = "failed"
detail.Message = msg
result.Details = append(result.Details, detail)
continue
}
tenantIDBySlug[strings.ToLower(record.Slug)] = tenant.ID
result.Created++
detail.Success = true
detail.Action = "created"
if h.Worksmobile != nil {
_ = h.Worksmobile.EnqueueTenantUpsertIfInScope(c.Context(), *tenant)
}
result.Details = append(result.Details, detail)
}
return c.JSON(result)
}
func tenantCSVReaderFromRequest(c *fiber.Ctx) (io.Reader, error) {
file, err := c.FormFile("file")
if err == nil && file != nil {
opened, err := file.Open()
if err != nil {
return nil, errors.New("failed to open uploaded file")
}
defer opened.Close()
data, err := io.ReadAll(opened)
if err != nil {
return nil, errors.New("failed to read uploaded file")
}
return bytes.NewReader(data), nil
}
body := c.Body()
if len(bytes.TrimSpace(body)) == 0 {
return nil, errors.New("csv file is required")
}
return bytes.NewReader(body), nil
}
func parseTenantCSVRecords(r io.Reader) ([]tenantCSVRecord, error) {
data, err := io.ReadAll(r)
if err != nil {
return nil, errors.New("failed to read csv")
}
data = bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF})
reader := csv.NewReader(bytes.NewReader(data))
reader.FieldsPerRecord = -1
rows, err := reader.ReadAll()
if err != nil {
return nil, fmt.Errorf("invalid csv: %w", err)
}
if len(rows) == 0 {
return nil, errors.New("csv is empty")
}
header := tenantCSVHeaderIndex(rows[0])
required := []string{"name", "type", "slug"}
for _, key := range required {
if _, ok := header[key]; !ok {
return nil, fmt.Errorf("missing required column: %s", key)
}
}
records := make([]tenantCSVRecord, 0, len(rows)-1)
for i, row := range rows[1:] {
if tenantCSVRowIsEmpty(row) {
continue
}
name := tenantCSVValue(row, header, "name")
if name == "" {
return nil, fmt.Errorf("row %d: name is required", i+2)
}
tenantType := normalizeTenantType(tenantCSVValue(row, header, "type"))
if tenantType == "" {
return nil, fmt.Errorf("row %d: invalid tenant type", i+2)
}
slug := utils.GenerateSlug(tenantCSVValue(row, header, "slug"))
if slug == "" {
return nil, fmt.Errorf("row %d: slug is required", i+2)
}
parentValue := tenantCSVValue(row, header, "parent_tenant_id")
var parentID *string
if parentValue != "" {
parentID = &parentValue
}
worksmobileSync, worksmobileSyncSet := tenantCSVWorksmobileSyncValue(row, header)
records = append(records, tenantCSVRecord{
TenantID: tenantCSVValue(row, header, "tenant_id"),
Name: name,
Type: tenantType,
ParentTenantID: parentID,
ParentTenantSlug: tenantCSVValue(row, header, "parent_tenant_slug"),
Slug: slug,
Memo: tenantCSVValue(row, header, "memo"),
Domains: splitTenantCSVDomains(tenantCSVValue(row, header, "email_domain")),
Visibility: tenantCSVValue(row, header, "visibility"),
OrgUnitType: tenantCSVValue(row, header, "org_unit_type"),
WorksmobileSync: worksmobileSync,
WorksmobileSyncSet: worksmobileSyncSet,
})
}
return records, nil
}
func tenantCSVHeaderIndex(header []string) map[string]int {
index := make(map[string]int, len(header))
aliases := map[string]string{
"id": "tenant_id",
"tenantid": "tenant_id",
"tenant_id": "tenant_id",
"name": "name",
"type": "type",
"parentid": "parent_tenant_id",
"parent_id": "parent_tenant_id",
"parenttenantid": "parent_tenant_id",
"parent_tenant_id": "parent_tenant_id",
"parenttenantslug": "parent_tenant_slug",
"parent_tenant_slug": "parent_tenant_slug",
"slug": "slug",
"memo": "memo",
"description": "memo",
"email-domain": "email_domain",
"emaildomain": "email_domain",
"email_domain": "email_domain",
"domain": "email_domain",
"domains": "email_domain",
"visibility": "visibility",
"public_setting": "visibility",
"publicsetting": "visibility",
"orgunittype": "org_unit_type",
"org_unit_type": "org_unit_type",
"org-unit-type": "org_unit_type",
"organizationtype": "org_unit_type",
"organization_type": "org_unit_type",
"orgtype": "org_unit_type",
"org_type": "org_unit_type",
"worksmobile": "worksmobile_sync",
"worksmobilesync": "worksmobile_sync",
"worksmobile_sync": "worksmobile_sync",
"works_sync": "worksmobile_sync",
"works": "worksmobile_sync",
"worksmobileexcluded": "worksmobile_excluded",
"worksmobile_excluded": "worksmobile_excluded",
}
for i, column := range header {
key := strings.ToLower(strings.TrimSpace(column))
key = strings.ReplaceAll(key, " ", "_")
if canonical, ok := aliases[key]; ok {
index[canonical] = i
}
}
return index
}
func tenantCSVValue(row []string, header map[string]int, key string) string {
idx, ok := header[key]
if !ok || idx >= len(row) {
return ""
}
return strings.TrimSpace(row[idx])
}
func tenantCSVWorksmobileSyncValue(row []string, header map[string]int) (string, bool) {
if _, ok := header["worksmobile_sync"]; ok {
value := tenantCSVValue(row, header, "worksmobile_sync")
if value == "" {
return "yes", true
}
return value, true
}
if _, ok := header["worksmobile_excluded"]; ok {
value := tenantCSVValue(row, header, "worksmobile_excluded")
excluded, err := normalizeTenantWorksmobileExcluded(value)
if err == nil && excluded {
return "no", true
}
if err == nil {
return "yes", true
}
return value, true
}
return "", false
}
func tenantCSVRowIsEmpty(row []string) bool {
for _, value := range row {
if strings.TrimSpace(value) != "" {
return false
}
}
return true
}
func includeCSVIds(c *fiber.Ctx) bool {
value := strings.ToLower(strings.TrimSpace(c.Query("includeIds")))
return value == "true" || value == "1" || value == "yes"
}
func orderTenantCSVRecordsByParentSlug(records []tenantCSVRecord) []tenantCSVRecord {
bySlug := make(map[string]tenantCSVRecord, len(records))
for _, record := range records {
bySlug[strings.ToLower(record.Slug)] = record
}
ordered := make([]tenantCSVRecord, 0, len(records))
visited := make(map[string]bool, len(records))
var visit func(record tenantCSVRecord)
visit = func(record tenantCSVRecord) {
key := strings.ToLower(record.Slug)
if visited[key] {
return
}
if record.ParentTenantSlug != "" {
if parent, ok := bySlug[strings.ToLower(record.ParentTenantSlug)]; ok {
visit(parent)
}
}
visited[key] = true
ordered = append(ordered, record)
}
for _, record := range records {
visit(record)
}
return ordered
}
func splitTenantCSVDomains(value string) []string {
value = strings.ReplaceAll(value, "\n", ";")
value = strings.ReplaceAll(value, ",", ";")
parts := strings.Split(value, ";")
domains := make([]string, 0, len(parts))
seen := make(map[string]bool, len(parts))
for _, part := range parts {
domainName := strings.ToLower(strings.TrimSpace(part))
if domainName == "" || seen[domainName] {
continue
}
seen[domainName] = true
domains = append(domains, domainName)
}
return domains
}
func normalizeTenantDomainInputs(values []string) []string {
seen := make(map[string]bool, len(values))
domains := make([]string, 0, len(values))
for _, value := range values {
for _, part := range strings.FieldsFunc(value, func(r rune) bool {
return r == ',' || r == ';' || r == '\n' || r == '\r' || r == '\t' || r == ' '
}) {
domainName := strings.ToLower(strings.TrimSpace(part))
if domainName == "" || seen[domainName] {
continue
}
seen[domainName] = true
domains = append(domains, domainName)
}
}
return domains
}
func normalizeTenantConfig(config map[string]any) (domain.JSONMap, error) {
normalized := make(domain.JSONMap, len(config))
orgUnitTypeError := "orgUnitType must be one of 실, 팀, TF, TF팀, 센터, 디비전, 셀, 본부, 지역본부, 부, 임원직속"
for key, value := range config {
if key == "userSchema" {
fields, err := normalizeTenantUserSchema(value)
if err != nil {
return nil, err
}
normalized[key] = fields
continue
}
if key == "visibility" {
visibility, ok := value.(string)
if !ok {
return nil, fmt.Errorf("visibility must be public, internal, or private")
}
visibility = strings.TrimSpace(strings.ToLower(visibility))
if visibility == "" || visibility == "public" {
normalized[key] = "public"
continue
}
if visibility != "internal" && visibility != "private" {
return nil, fmt.Errorf("visibility must be public, internal, or private")
}
normalized[key] = visibility
continue
}
if key == "orgUnitType" {
orgUnitType, ok := value.(string)
if !ok {
return nil, errors.New(orgUnitTypeError)
}
orgUnitType = strings.TrimSpace(orgUnitType)
if orgUnitType == "" {
continue
}
if !isAllowedOrgUnitType(orgUnitType) {
return nil, errors.New(orgUnitTypeError)
}
normalized[key] = orgUnitType
continue
}
if key == "worksmobileExcluded" {
excluded, err := normalizeTenantWorksmobileExcluded(value)
if err != nil {
return nil, err
}
normalized[key] = excluded
continue
}
normalized[key] = value
}
return normalized, nil
}
func normalizeTenantWorksmobileExcluded(value any) (bool, error) {
switch typed := value.(type) {
case bool:
return typed, nil
case string:
normalized := strings.ToLower(strings.TrimSpace(typed))
switch normalized {
case "", "yes", "y", "true", "1", "on", "sync", "linked", "연동":
return false, nil
case "no", "n", "false", "0", "off", "none", "excluded", "exclude", "not_sync", "not-synced", "미연동", "연동안함", "제외":
return true, nil
default:
return false, fmt.Errorf("worksmobile_sync must be yes or no")
}
default:
return false, fmt.Errorf("worksmobile_sync must be yes or no")
}
}
func isAllowedOrgUnitType(value string) bool {
switch value {
case "실", "팀", "TF", "TF팀", "센터", "디비전", "셀", "본부", "지역본부", "부", "임원직속":
return true
default:
return false
}
}
func hasTenantOrgConfig(config domain.JSONMap) bool {
if config == nil {
return false
}
_, hasVisibility := config["visibility"]
_, hasOrgUnitType := config["orgUnitType"]
return hasVisibility || hasOrgUnitType
}
func isHanmacFamilyDescendantTenant(tenant domain.Tenant, tenants []domain.Tenant) bool {
if strings.EqualFold(tenant.Slug, "hanmac-family") {
return false
}
byID := make(map[string]domain.Tenant, len(tenants)+1)
for _, item := range tenants {
byID[item.ID] = item
}
byID[tenant.ID] = tenant
parentID := tenant.ParentID
visited := make(map[string]bool)
for parentID != nil && *parentID != "" {
if visited[*parentID] {
return false
}
visited[*parentID] = true
parent, ok := byID[*parentID]
if !ok {
return false
}
if strings.EqualFold(parent.Slug, "hanmac-family") {
return true
}
parentID = parent.ParentID
}
return false
}
func validateTenantOrgConfigScope(tenant domain.Tenant, tenants []domain.Tenant, config domain.JSONMap) error {
if !hasTenantOrgConfig(config) {
return nil
}
if isHanmacFamilyDescendantTenant(tenant, tenants) {
return nil
}
return fmt.Errorf("tenant org config is allowed only hanmac-family descendants")
}
func tenantVisibility(config domain.JSONMap) string {
visibility, _ := config["visibility"].(string)
switch strings.ToLower(strings.TrimSpace(visibility)) {
case "internal":
return "internal"
case "private":
return "private"
default:
return "public"
}
}
func tenantCSVOrgConfigValues(config domain.JSONMap) (string, string, string) {
visibility := tenantVisibility(config)
orgUnitType, _ := config["orgUnitType"].(string)
worksmobileSync := "yes"
if excluded, err := normalizeTenantWorksmobileExcluded(config["worksmobileExcluded"]); err == nil && excluded {
worksmobileSync = "no"
}
return visibility, strings.TrimSpace(orgUnitType), worksmobileSync
}
func tenantCSVRecordConfig(record tenantCSVRecord) (domain.JSONMap, error) {
config := map[string]any{}
if strings.TrimSpace(record.Visibility) != "" {
config["visibility"] = record.Visibility
}
if strings.TrimSpace(record.OrgUnitType) != "" {
config["orgUnitType"] = record.OrgUnitType
}
if record.WorksmobileSyncSet {
config["worksmobileExcluded"] = record.WorksmobileSync
}
if len(config) == 0 {
return nil, nil
}
return normalizeTenantConfig(config)
}
func mergeTenantCSVRecordConfig(current domain.JSONMap, record tenantCSVRecord) (domain.JSONMap, bool, error) {
recordConfig, err := tenantCSVRecordConfig(record)
if err != nil {
return nil, false, err
}
if len(recordConfig) == 0 {
return current, false, nil
}
merged := make(domain.JSONMap, len(current)+len(recordConfig))
maps.Copy(merged, current)
maps.Copy(merged, recordConfig)
return merged, true, nil
}
func filterPublicTenants(tenants []domain.Tenant) []domain.Tenant {
excludedIDs := make(map[string]bool)
for _, tenant := range tenants {
visibility := tenantVisibility(tenant.Config)
if visibility == "internal" || visibility == "private" {
excludedIDs[tenant.ID] = true
}
}
changed := true
for changed {
changed = false
for _, tenant := range tenants {
if tenant.ParentID != nil && excludedIDs[*tenant.ParentID] && !excludedIDs[tenant.ID] {
excludedIDs[tenant.ID] = true
changed = true
}
}
}
filtered := make([]domain.Tenant, 0, len(tenants))
for _, tenant := range tenants {
if !excludedIDs[tenant.ID] {
filtered = append(filtered, tenant)
}
}
return filtered
}
func (h *TenantHandler) filterPrivateTenantsForProfile(ctx context.Context, tenants []domain.Tenant, profile *domain.UserProfileResponse) ([]domain.Tenant, error) {
if profile != nil && domain.NormalizeRole(profile.Role) == domain.RoleSuperAdmin {
return tenants, nil
}
privateRoots := privateTenantRootIDs(tenants)
if len(privateRoots) == 0 {
return tenants, nil
}
allowedPrivateRoots := make(map[string]bool, len(privateRoots))
for _, rootID := range privateRoots {
allowed, err := h.canViewPrivateTenant(ctx, profile, rootID, tenants)
if err != nil {
return nil, err
}
if allowed {
allowedPrivateRoots[rootID] = true
}
}
excludedIDs := make(map[string]bool)
for _, rootID := range privateRoots {
if !allowedPrivateRoots[rootID] {
excludedIDs[rootID] = true
}
}
changed := true
for changed {
changed = false
for _, tenant := range tenants {
if tenant.ParentID != nil && excludedIDs[*tenant.ParentID] && !excludedIDs[tenant.ID] {
excludedIDs[tenant.ID] = true
changed = true
}
}
}
filtered := make([]domain.Tenant, 0, len(tenants))
for _, tenant := range tenants {
if !excludedIDs[tenant.ID] {
filtered = append(filtered, tenant)
}
}
return filtered, nil
}
func privateTenantRootIDs(tenants []domain.Tenant) []string {
tenantByID := make(map[string]domain.Tenant, len(tenants))
for _, tenant := range tenants {
tenantByID[tenant.ID] = tenant
}
roots := make([]string, 0)
for _, tenant := range tenants {
if tenantVisibility(tenant.Config) != "private" {
continue
}
if tenant.ParentID != nil {
parent, ok := tenantByID[*tenant.ParentID]
if ok && tenantVisibility(parent.Config) == "private" {
continue
}
}
roots = append(roots, tenant.ID)
}
return roots
}
func (h *TenantHandler) canViewPrivateTenant(ctx context.Context, profile *domain.UserProfileResponse, privateRootID string, tenants []domain.Tenant) (bool, error) {
if profile == nil {
return false, nil
}
if profileCanManageTenantOrAncestor(profile, privateRootID, tenants) {
return true, nil
}
if h.Keto == nil || strings.TrimSpace(profile.ID) == "" {
return false, nil
}
subject := "User:" + profile.ID
for _, relation := range []string{"view_private", "view_private_descendants", "view", "manage"} {
allowed, err := h.Keto.CheckPermission(ctx, subject, "Tenant", privateRootID, relation)
if err != nil {
return false, fmt.Errorf("private tenant permission check failed: %w", err)
}
if allowed {
return true, nil
}
}
for _, ancestorID := range tenantAncestorIDs(privateRootID, tenants) {
allowed, err := h.Keto.CheckPermission(ctx, subject, "Tenant", ancestorID, "view_private_descendants")
if err != nil {
return false, fmt.Errorf("private tenant descendant permission check failed: %w", err)
}
if allowed {
return true, nil
}
}
return false, nil
}
func profileCanManageTenantOrAncestor(profile *domain.UserProfileResponse, tenantID string, tenants []domain.Tenant) bool {
manageableIDs := make(map[string]bool, len(profile.ManageableTenants))
for _, tenant := range profile.ManageableTenants {
if tenant.ID != "" {
manageableIDs[tenant.ID] = true
}
}
if len(manageableIDs) == 0 {
return false
}
if manageableIDs[tenantID] {
return true
}
for _, ancestorID := range tenantAncestorIDs(tenantID, tenants) {
if manageableIDs[ancestorID] {
return true
}
}
return false
}
func tenantAncestorIDs(tenantID string, tenants []domain.Tenant) []string {
tenantByID := make(map[string]domain.Tenant, len(tenants))
for _, tenant := range tenants {
tenantByID[tenant.ID] = tenant
}
ancestors := make([]string, 0)
visited := map[string]bool{}
current, ok := tenantByID[tenantID]
for ok && current.ParentID != nil && *current.ParentID != "" {
parentID := *current.ParentID
if visited[parentID] {
break
}
visited[parentID] = true
ancestors = append(ancestors, parentID)
current, ok = tenantByID[parentID]
}
return ancestors
}
func normalizeTenantUserSchema(value any) ([]any, error) {
if value == nil {
return nil, nil
}
rawFields, ok := value.([]any)
if !ok {
return nil, fmt.Errorf("userSchema must be an array")
}
fields := make([]any, 0, len(rawFields))
for _, raw := range rawFields {
field, ok := raw.(map[string]any)
if !ok {
return nil, fmt.Errorf("userSchema fields must be objects")
}
normalized := make(map[string]any, len(field))
for key, value := range field {
if key == "maxLength" {
continue
}
normalized[key] = value
}
isLoginID, _ := normalized["isLoginId"].(bool)
if isLoginID {
fieldType, _ := normalized["type"].(string)
if fieldType != "" && fieldType != "text" {
return nil, fmt.Errorf("login ID fields must be text")
}
normalized["type"] = "text"
normalized["indexed"] = true
} else if indexed, ok := normalized["indexed"].(bool); !ok || !indexed {
normalized["indexed"] = false
}
fields = append(fields, normalized)
}
return fields, nil
}
func normalizeTenantDomainForceSet(values []string) map[string]bool {
domains := normalizeTenantDomainInputs(values)
force := make(map[string]bool, len(domains))
for _, domainName := range domains {
force[domainName] = true
}
return force
}
func tenantDomainConflictJSON(c *fiber.Ctx, conflicts []tenantDomainConflict) error {
return c.Status(fiber.StatusConflict).JSON(fiber.Map{
"code": "tenant_domain_conflict",
"error": "domain is already assigned to another tenant",
"conflicts": conflicts,
})
}
func (h *TenantHandler) findTenantDomainConflicts(ctx context.Context, tenantID string, domains []string, forceDomains []string) ([]tenantDomainConflict, error) {
if h.DB == nil || h.DB.Config == nil || len(domains) == 0 {
return nil, nil
}
force := normalizeTenantDomainForceSet(forceDomains)
var rows []domain.TenantDomain
query := h.DB.WithContext(ctx).Where("domain IN ?", domains)
if tenantID != "" {
query = query.Where("tenant_id <> ?", tenantID)
}
if err := query.Find(&rows).Error; err != nil {
return nil, err
}
conflicts := make([]tenantDomainConflict, 0, len(rows))
tenantIDs := make([]string, 0, len(rows))
seenTenantIDs := make(map[string]bool, len(rows))
for _, row := range rows {
if force[row.Domain] {
continue
}
if !seenTenantIDs[row.TenantID] {
seenTenantIDs[row.TenantID] = true
tenantIDs = append(tenantIDs, row.TenantID)
}
}
tenantsByID := make(map[string]domain.Tenant, len(tenantIDs))
if len(tenantIDs) > 0 {
var tenants []domain.Tenant
if err := h.DB.WithContext(ctx).Where("id IN ?", tenantIDs).Find(&tenants).Error; err != nil {
return nil, err
}
for _, tenant := range tenants {
tenantsByID[tenant.ID] = tenant
}
}
for _, row := range rows {
if force[row.Domain] {
continue
}
conflict := tenantDomainConflict{
Domain: row.Domain,
TenantID: row.TenantID,
}
if tenant, ok := tenantsByID[row.TenantID]; ok {
conflict.TenantName = tenant.Name
conflict.TenantSlug = tenant.Slug
}
conflicts = append(conflicts, conflict)
}
return conflicts, nil
}
func (h *TenantHandler) replaceTenantDomains(ctx context.Context, tenantID string, domains []string, forceDomains []string) error {
if h.DB == nil {
return errors.New("database not available")
}
if h.DB.Config == nil {
return nil
}
deleteQuery := h.DB.WithContext(ctx).Where("tenant_id = ?", tenantID)
if len(domains) > 0 {
deleteQuery = deleteQuery.Where("domain NOT IN ?", domains)
}
if err := deleteQuery.Delete(&domain.TenantDomain{}).Error; err != nil {
return fmt.Errorf("failed to clear old domains: %w", err)
}
for _, domainName := range domains {
var existing domain.TenantDomain
err := h.DB.WithContext(ctx).Unscoped().
Where("tenant_id = ? AND domain = ?", tenantID, domainName).
First(&existing).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
if err := repository.NewTenantRepository(h.DB).AddDomain(ctx, tenantID, domainName, true); err != nil {
return fmt.Errorf("failed to add domain: %s", domainName)
}
continue
}
if err != nil {
return err
}
if err := h.DB.WithContext(ctx).Unscoped().Model(&existing).Updates(map[string]any{
"verified": true,
"deleted_at": nil,
}).Error; err != nil {
return fmt.Errorf("failed to add domain: %s", domainName)
}
}
return nil
}
func (h *TenantHandler) upsertTenantCSVRecord(c *fiber.Ctx, record tenantCSVRecord) (*domain.Tenant, []string, error) {
if h.DB == nil {
if record.TenantID != "" {
return nil, nil, errors.New("database not available for tenant update")
}
return nil, nil, nil
}
var tenant domain.Tenant
query := h.DB.Preload("Domains")
var err error
if record.TenantID != "" {
err = query.First(&tenant, "id = ?", record.TenantID).Error
} else {
err = query.First(&tenant, "slug = ?", record.Slug).Error
}
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil, nil
}
if err != nil {
return nil, nil, err
}
modifiedFields := []string{}
if tenant.Name != record.Name {
tenant.Name = record.Name
modifiedFields = append(modifiedFields, "Name")
}
if tenant.Type != record.Type {
tenant.Type = record.Type
modifiedFields = append(modifiedFields, "Type")
}
if record.ParentTenantID != nil {
oldParentID := ""
if tenant.ParentID != nil {
oldParentID = *tenant.ParentID
}
if oldParentID != *record.ParentTenantID {
tenant.ParentID = record.ParentTenantID
modifiedFields = append(modifiedFields, "ParentID")
}
} else if tenant.ParentID != nil {
tenant.ParentID = nil
modifiedFields = append(modifiedFields, "ParentID")
}
if tenant.Slug != record.Slug {
tenant.Slug = record.Slug
modifiedFields = append(modifiedFields, "Slug")
}
if tenant.Description != record.Memo {
tenant.Description = record.Memo
modifiedFields = append(modifiedFields, "Description")
}
if tenant.Status == "" {
tenant.Status = domain.TenantStatusActive
}
mergedConfig, changedConfig, err := mergeTenantCSVRecordConfig(tenant.Config, record)
if err != nil {
return nil, nil, err
}
if changedConfig {
tenant.Config = mergedConfig
modifiedFields = append(modifiedFields, "Config")
}
existingDomains := make([]string, len(tenant.Domains))
for i, d := range tenant.Domains {
existingDomains[i] = d.Domain
}
sort.Strings(existingDomains)
newDomains := append([]string(nil), record.Domains...)
sort.Strings(newDomains)
if !reflect.DeepEqual(existingDomains, newDomains) {
modifiedFields = append(modifiedFields, "Domains")
}
if len(modifiedFields) == 0 {
return &tenant, nil, nil
}
if err := h.DB.Save(&tenant).Error; err != nil {
return nil, nil, err
}
if err := h.DB.Delete(&domain.TenantDomain{}, "tenant_id = ?", tenant.ID).Error; err != nil {
return nil, nil, err
}
repo := repository.NewTenantRepository(h.DB)
for _, domainName := range record.Domains {
if err := repo.AddDomain(c.Context(), tenant.ID, domainName, true); err != nil {
return nil, nil, err
}
}
return &tenant, modifiedFields, nil
}
func (h *TenantHandler) createTenantCSVRecord(c *fiber.Ctx, record tenantCSVRecord, creatorID string) (*domain.Tenant, error) {
if h.DB != nil && record.TenantID != "" {
var exists int64
if err := h.DB.Unscoped().Model(&domain.Tenant{}).Where("slug = ?", record.Slug).Count(&exists).Error; err != nil {
return nil, err
}
if exists > 0 {
return nil, errors.New("tenant slug already exists")
}
tenant := domain.Tenant{
ID: record.TenantID,
Type: record.Type,
ParentID: record.ParentTenantID,
Name: record.Name,
Slug: record.Slug,
Description: record.Memo,
Status: domain.TenantStatusActive,
}
config, _, err := mergeTenantCSVRecordConfig(nil, record)
if err != nil {
return nil, err
}
if len(config) > 0 {
tenant.Config = config
}
if err := h.DB.Create(&tenant).Error; err != nil {
return nil, err
}
if h.KetoOutbox != nil {
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
Namespace: "Tenant",
Object: tenant.ID,
Relation: "admins",
Subject: "System:global#super_admins",
Action: domain.KetoOutboxActionCreate,
})
if tenant.ParentID != nil {
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
Namespace: "Tenant",
Object: tenant.ID,
Relation: "parents",
Subject: "Tenant:" + *tenant.ParentID,
Action: domain.KetoOutboxActionCreate,
})
}
if creatorID != "" {
for _, relation := range []string{"owners", "admins", "members"} {
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
Namespace: "Tenant",
Object: tenant.ID,
Relation: relation,
Subject: "User:" + creatorID,
Action: domain.KetoOutboxActionCreate,
})
}
}
}
repo := repository.NewTenantRepository(h.DB)
for _, domainName := range record.Domains {
if err := repo.AddDomain(c.Context(), tenant.ID, domainName, true); err != nil {
return nil, err
}
}
return &tenant, nil
}
tenant, err := h.Service.RegisterTenant(c.Context(), record.Name, record.Slug, record.Type, record.Memo, record.Domains, record.ParentTenantID, creatorID)
if err != nil || tenant == nil {
return tenant, err
}
config, changedConfig, err := mergeTenantCSVRecordConfig(tenant.Config, record)
if err != nil {
return nil, err
}
if changedConfig {
if h.DB == nil {
return nil, errors.New("database not available for tenant config import")
}
tenant.Config = config
if err := h.DB.Save(tenant).Error; err != nil {
return nil, err
}
}
return tenant, err
}
func (h *TenantHandler) GetTenant(c *fiber.Ctx) error {
if h.DB == nil {
return errorJSON(c, fiber.StatusServiceUnavailable, "database not available")
}
tenantID := strings.TrimSpace(c.Params("id"))
if tenantID == "" {
return errorJSON(c, fiber.StatusBadRequest, "tenant id is required")
}
var tenant domain.Tenant
if err := h.DB.Preload("Domains").First(&tenant, "id = ?", tenantID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errorJSON(c, fiber.StatusNotFound, "tenant not found")
}
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
memberCounts, totalMemberCounts, err := h.countTenantMembersFromProjection(c.Context(), []domain.Tenant{tenant})
if err != nil {
return errorJSON(c, fiber.StatusServiceUnavailable, err.Error())
}
summary := mapTenantSummary(tenant)
summary.MemberCount = memberCounts[tenant.ID]
summary.TotalMemberCount = totalMemberCounts[tenant.ID]
return c.JSON(summary)
}
func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error {
if h.DB == nil {
return errorJSON(c, fiber.StatusServiceUnavailable, "database not available")
}
var req struct {
Name string `json:"name"`
Slug string `json:"slug"`
Type string `json:"type"`
Description string `json:"description"`
Status string `json:"status"`
Domains []string `json:"domains"`
ForceDomains []string `json:"forceDomainConflicts"`
ParentID *string `json:"parentId"`
Config map[string]any `json:"config"`
}
if err := c.BodyParser(&req); err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
}
name := strings.TrimSpace(req.Name)
if name == "" {
return errorJSON(c, fiber.StatusBadRequest, "name is required")
}
tenantType := normalizeTenantType(req.Type)
if tenantType == "" {
tenantType = domain.TenantTypeCompany // Default to COMPANY
}
slug := req.Slug
if slug == "" {
slug = utils.GenerateUniqueSlug(name, func(s string) bool {
var count int64
h.DB.Unscoped().Model(&domain.Tenant{}).Where("slug = ?", s).Count(&count)
return count > 0
})
} else {
slug = utils.GenerateSlug(slug)
}
if slug == "" {
return errorJSON(c, fiber.StatusBadRequest, "slug is required")
}
status := normalizeTenantStatus(req.Status)
if status == "" {
status = "active"
}
// Use Service
var parentID *string
if req.ParentID != nil && strings.TrimSpace(*req.ParentID) != "" {
pid := strings.TrimSpace(*req.ParentID)
parentID = &pid
}
// Extract creator ID if present
creatorID := ""
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok {
creatorID = profile.ID
}
normalizedDomains := normalizeTenantDomainInputs(req.Domains)
conflicts, err := h.findTenantDomainConflicts(c.Context(), "", normalizedDomains, req.ForceDomains)
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
if len(conflicts) > 0 {
return tenantDomainConflictJSON(c, conflicts)
}
tenant, err := h.Service.RegisterTenant(c.Context(), name, slug, tenantType, req.Description, nil, parentID, creatorID)
if err != nil {
if strings.Contains(err.Error(), "already exists") {
return errorJSON(c, fiber.StatusConflict, err.Error())
}
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
summary := mapTenantSummary(*tenant)
summary.MemberCount = 0
summary.TotalMemberCount = 0
if req.Config != nil {
config, err := normalizeTenantConfig(req.Config)
if err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error())
}
var tenants []domain.Tenant
if hasTenantOrgConfig(config) {
if err := h.DB.Find(&tenants).Error; err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
if err := validateTenantOrgConfigScope(*tenant, tenants, config); err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error())
}
}
tenant.Config = config
h.DB.Save(tenant)
summary.Config = tenant.Config
}
if err := h.replaceTenantDomains(c.Context(), tenant.ID, normalizedDomains, req.ForceDomains); err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
if len(normalizedDomains) > 0 {
summary.Domains = normalizedDomains
}
if h.Worksmobile != nil {
if refreshed := h.DB.Preload("Domains").First(tenant, "id = ?", tenant.ID); refreshed.Error == nil {
if err := h.Worksmobile.EnqueueTenantUpsertIfInScope(c.Context(), *tenant); err != nil {
fmt.Printf("[TenantHandler] failed to enqueue Worksmobile tenant sync: %v\n", err)
}
}
}
return c.Status(fiber.StatusCreated).JSON(summary)
}
func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
if h.DB == nil {
return errorJSON(c, fiber.StatusServiceUnavailable, "database not available")
}
tenantID := strings.TrimSpace(c.Params("id"))
if tenantID == "" {
return errorJSON(c, fiber.StatusBadRequest, "tenant id is required")
}
var tenant domain.Tenant
if err := h.DB.First(&tenant, "id = ?", tenantID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errorJSON(c, fiber.StatusNotFound, "tenant not found")
}
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
var req struct {
Name *string `json:"name"`
Type *string `json:"type"`
Slug *string `json:"slug"`
Description *string `json:"description"`
Status *string `json:"status"`
ParentID *string `json:"parentId"`
Domains []string `json:"domains"`
ForceDomains []string `json:"forceDomainConflicts"`
Config map[string]any `json:"config"`
}
if err := c.BodyParser(&req); err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
}
if req.Name != nil {
name := strings.TrimSpace(*req.Name)
if name == "" {
return errorJSON(c, fiber.StatusBadRequest, "name cannot be empty")
}
tenant.Name = name
}
if req.Type != nil {
tenantType := normalizeTenantType(*req.Type)
if tenantType == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid tenant type"})
}
tenant.Type = tenantType
}
if req.Slug != nil {
slug := utils.GenerateSlug(*req.Slug)
if slug == "" {
return errorJSON(c, fiber.StatusBadRequest, "slug cannot be empty")
}
if slug != tenant.Slug {
var exists domain.Tenant
if err := h.DB.Unscoped().Where("slug = ?", slug).First(&exists).Error; err == nil {
return errorJSON(c, fiber.StatusConflict, "slug already exists")
} else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
tenant.Slug = slug
}
}
if req.Description != nil {
tenant.Description = strings.TrimSpace(*req.Description)
}
if req.Status != nil {
status := normalizeTenantStatus(*req.Status)
if status == "" {
return errorJSON(c, fiber.StatusBadRequest, "status must be active or inactive")
}
tenant.Status = status
}
if req.ParentID != nil {
pid := strings.TrimSpace(*req.ParentID)
if pid == "" {
tenant.ParentID = nil
} else {
// 순환 참조(Circular Dependency) 방지 로직:
// 새로운 부모(pid)부터 상위로 탐색하면서 현재 테넌트(tenant.ID)가 나오면 순환 참조로 간주함
checkID := pid
for checkID != "" {
if checkID == tenant.ID {
return errorJSON(c, fiber.StatusConflict, "순환 참조 오류: 하위 테넌트를 상위 테넌트로 지정할 수 없습니다.")
}
var pTenant domain.Tenant
if err := h.DB.Select("id, parent_id").First(&pTenant, "id = ?", checkID).Error; err != nil {
break // 데이터를 찾을 수 없거나 에러 발생 시 반복문 종료 (추후 외래키 제약조건 등에서 에러 발생)
}
if pTenant.ParentID != nil {
checkID = *pTenant.ParentID
} else {
break
}
}
tenant.ParentID = &pid
}
// [Keto] Sync hierarchy via Outbox
if h.KetoOutbox != nil {
if tenant.ParentID != nil {
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
Namespace: "Tenant",
Object: tenant.ID,
Relation: "parents",
Subject: "Tenant:" + *tenant.ParentID,
Action: domain.KetoOutboxActionCreate,
})
} else {
// We don't have enough info here to delete specific parent if we don't know the old one,
// but for now we focus on adding.
}
}
}
if req.Config != nil {
config, err := normalizeTenantConfig(req.Config)
if err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error())
}
var tenants []domain.Tenant
if hasTenantOrgConfig(config) {
if err := h.DB.Find(&tenants).Error; err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
if err := validateTenantOrgConfigScope(tenant, tenants, config); err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error())
}
}
tenant.Config = config
}
if err := h.DB.Save(&tenant).Error; err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
// Update domains if provided
if req.Domains != nil {
normalizedDomains := normalizeTenantDomainInputs(req.Domains)
conflicts, err := h.findTenantDomainConflicts(c.Context(), tenant.ID, normalizedDomains, req.ForceDomains)
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
if len(conflicts) > 0 {
return tenantDomainConflictJSON(c, conflicts)
}
if err := h.replaceTenantDomains(c.Context(), tenant.ID, normalizedDomains, req.ForceDomains); err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
}
// Refetch to get updated relations
h.DB.Preload("Domains").First(&tenant, "id = ?", tenant.ID)
if h.Worksmobile != nil {
if err := h.Worksmobile.EnqueueTenantUpsertIfInScope(c.Context(), tenant); err != nil {
fmt.Printf("[TenantHandler] failed to enqueue Worksmobile tenant update sync: %v\n", err)
}
}
return c.JSON(mapTenantSummary(tenant))
}
func (h *TenantHandler) DeleteTenant(c *fiber.Ctx) error {
if h.DB == nil {
return errorJSON(c, fiber.StatusServiceUnavailable, "database not available")
}
tenantID := strings.TrimSpace(c.Params("id"))
if tenantID == "" {
return errorJSON(c, fiber.StatusBadRequest, "tenant id is required")
}
var tenant domain.Tenant
if err := h.DB.First(&tenant, "id = ?", tenantID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errorJSON(c, fiber.StatusNotFound, "tenant not found")
}
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
if bootstrap.IsSeedTenantSlug(tenant.Slug) {
return seedTenantDeleteError(c)
}
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())
}
// Rename slug to release it for reuse before soft delete
deletedSlug := tenant.Slug + "-deleted-" + time.Now().Format("20060102150405")
if err := h.DB.Model(&tenant).Update("slug", deletedSlug).Error; err != nil {
return errorJSON(c, fiber.StatusInternalServerError, "failed to release slug")
}
if err := h.DB.Delete(&tenant).Error; err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
if h.Worksmobile != nil {
if err := h.Worksmobile.EnqueueTenantDeleteIfInScope(c.Context(), tenant); err != nil {
fmt.Printf("[TenantHandler] failed to enqueue Worksmobile tenant delete sync: %v\n", err)
}
}
return c.SendStatus(fiber.StatusNoContent)
}
func (h *TenantHandler) ListAdmins(c *fiber.Ctx) error {
tenantID := c.Params("id")
if tenantID == "" {
return errorJSON(c, fiber.StatusBadRequest, "tenant id is required")
}
// Fetch admins from Keto
relations, err := h.Keto.ListRelations(c.Context(), "Tenant", tenantID, "admins", "")
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
type adminInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
admins := []adminInfo{}
for _, rel := range relations {
if !strings.HasPrefix(rel.SubjectID, "User:") {
continue
}
userID := strings.TrimPrefix(rel.SubjectID, "User:")
// Fetch user details - Try Kratos first, then local DB
name := "Unknown"
email := "Unknown"
identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID)
if err == nil && identity != nil {
if n, ok := identity.Traits["name"].(string); ok {
name = n
}
if e, ok := identity.Traits["email"].(string); ok {
email = e
}
} else if h.UserRepo != nil {
// Fallback to local DB (useful for Mock users or users not yet synced/migrated to Kratos)
user, err := h.UserRepo.FindByID(c.Context(), userID)
if err == nil && user != nil {
name = user.Name
email = user.Email
} else if userID == "00000000-0000-0000-0000-000000000000" {
name = "Dev Mock User"
email = "mock@hmac.kr"
}
}
admins = append(admins, adminInfo{
ID: userID,
Name: name,
Email: email,
})
}
return c.JSON(admins)
}
func (h *TenantHandler) AddAdmin(c *fiber.Ctx) error {
tenantID := c.Params("id")
userID := c.Params("userId")
if tenantID == "" || userID == "" {
return errorJSON(c, fiber.StatusBadRequest, "tenantId and userId are required")
}
if h.Keto != nil {
relations, err := h.Keto.ListRelations(c.Context(), "Tenant", tenantID, "admins", "User:"+userID)
if err == nil && len(relations) > 0 {
return errorJSON(c, fiber.StatusConflict, "이미 관리자로 등록된 사용자입니다.")
}
}
if h.KetoOutbox != nil {
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
Namespace: "Tenant",
Object: tenantID,
Relation: "admins",
Subject: "User:" + userID,
Action: domain.KetoOutboxActionCreate,
})
// Also add as member for UI visibility/ReBAC logic
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
Namespace: "Tenant",
Object: tenantID,
Relation: "members",
Subject: "User:" + userID,
Action: domain.KetoOutboxActionCreate,
})
}
return c.SendStatus(fiber.StatusOK)
}
func (h *TenantHandler) RemoveAdmin(c *fiber.Ctx) error {
tenantID := c.Params("id")
userID := c.Params("userId")
if tenantID == "" || userID == "" {
return errorJSON(c, fiber.StatusBadRequest, "tenantId and userId are required")
}
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok {
if profile.ID == userID {
return errorJSON(c, fiber.StatusBadRequest, "cannot remove yourself from admin role")
}
}
if h.Keto != nil {
if relations, err := h.Keto.ListRelations(c.Context(), "Tenant", tenantID, "admins", ""); err == nil {
adminCount := 0
isTargetAdmin := false
for _, rel := range relations {
if strings.HasPrefix(rel.SubjectID, "User:") {
adminCount++
if rel.SubjectID == "User:"+userID {
isTargetAdmin = true
}
}
}
if isTargetAdmin && adminCount <= 1 {
return errorJSON(c, fiber.StatusBadRequest, "cannot remove the last admin")
}
}
}
if h.KetoOutbox != nil {
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
Namespace: "Tenant",
Object: tenantID,
Relation: "admins",
Subject: "User:" + userID,
Action: domain.KetoOutboxActionDelete,
})
}
return c.SendStatus(fiber.StatusNoContent)
}
func (h *TenantHandler) ListOwners(c *fiber.Ctx) error {
tenantID := c.Params("id")
if tenantID == "" {
return errorJSON(c, fiber.StatusBadRequest, "tenant id is required")
}
// Fetch owners from Keto
relations, err := h.Keto.ListRelations(c.Context(), "Tenant", tenantID, "owners", "")
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
type ownerInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
owners := []ownerInfo{}
for _, rel := range relations {
if !strings.HasPrefix(rel.SubjectID, "User:") {
continue
}
userID := strings.TrimPrefix(rel.SubjectID, "User:")
// Fetch user details - Try Kratos first, then local DB
name := "Unknown"
email := "Unknown"
identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID)
if err == nil && identity != nil {
if n, ok := identity.Traits["name"].(string); ok {
name = n
}
if e, ok := identity.Traits["email"].(string); ok {
email = e
}
} else if h.UserRepo != nil {
// Fallback to local DB
user, err := h.UserRepo.FindByID(c.Context(), userID)
if err == nil && user != nil {
name = user.Name
email = user.Email
} else if userID == "00000000-0000-0000-0000-000000000000" {
name = "Dev Mock User"
email = "mock@hmac.kr"
}
}
owners = append(owners, ownerInfo{
ID: userID,
Name: name,
Email: email,
})
}
return c.JSON(owners)
}
func (h *TenantHandler) AddOwner(c *fiber.Ctx) error {
tenantID := c.Params("id")
userID := c.Params("userId")
if tenantID == "" || userID == "" {
return errorJSON(c, fiber.StatusBadRequest, "tenantId and userId are required")
}
if h.Keto != nil {
relations, err := h.Keto.ListRelations(c.Context(), "Tenant", tenantID, "owners", "User:"+userID)
if err == nil && len(relations) > 0 {
return errorJSON(c, fiber.StatusConflict, "이미 소유자로 등록된 사용자입니다.")
}
}
if h.KetoOutbox != nil {
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
Namespace: "Tenant",
Object: tenantID,
Relation: "owners",
Subject: "User:" + userID,
Action: domain.KetoOutboxActionCreate,
})
// Also add as member for UI visibility/ReBAC logic
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
Namespace: "Tenant",
Object: tenantID,
Relation: "members",
Subject: "User:" + userID,
Action: domain.KetoOutboxActionCreate,
})
}
return c.SendStatus(fiber.StatusOK)
}
func (h *TenantHandler) RemoveOwner(c *fiber.Ctx) error {
tenantID := c.Params("id")
userID := c.Params("userId")
if tenantID == "" || userID == "" {
return errorJSON(c, fiber.StatusBadRequest, "tenantId and userId are required")
}
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok {
if profile.ID == userID {
return errorJSON(c, fiber.StatusBadRequest, "cannot remove yourself from owner role")
}
}
if h.Keto != nil {
if relations, err := h.Keto.ListRelations(c.Context(), "Tenant", tenantID, "owners", ""); err == nil {
ownerCount := 0
isTargetOwner := false
for _, rel := range relations {
if strings.HasPrefix(rel.SubjectID, "User:") {
ownerCount++
if rel.SubjectID == "User:"+userID {
isTargetOwner = true
}
}
}
if isTargetOwner && ownerCount <= 1 {
return errorJSON(c, fiber.StatusBadRequest, "cannot remove the last owner")
}
}
}
if h.KetoOutbox != nil {
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
Namespace: "Tenant",
Object: tenantID,
Relation: "owners",
Subject: "User:" + userID,
Action: domain.KetoOutboxActionDelete,
})
}
return c.SendStatus(fiber.StatusNoContent)
}
func (h *TenantHandler) DeleteTenantsBulk(c *fiber.Ctx) error {
var req struct {
IDs []string `json:"ids"`
}
if err := c.BodyParser(&req); err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
}
if len(req.IDs) == 0 {
return errorJSON(c, fiber.StatusBadRequest, "no IDs provided")
}
// Permission check: Super Admin can delete anything.
// Tenant Admin should theoretically only delete manageable sub-tenants,
// but currently bulk delete is intended for Super Admin.
profile, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
if profile == nil || domain.NormalizeRole(profile.Role) != domain.RoleSuperAdmin {
return errorJSON(c, fiber.StatusForbidden, "only super admin can perform bulk deletion")
}
protectedSlugs := seedTenantSlugsForDeleteGuard()
if len(protectedSlugs) > 0 {
var protectedCount int64
if err := h.DB.Model(&domain.Tenant{}).
Where("id IN ?", req.IDs).
Where("slug IN ?", protectedSlugs).
Count(&protectedCount).Error; err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
if protectedCount > 0 {
return seedTenantDeleteError(c)
}
}
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())
}
if err := h.Service.DeleteTenantsBulk(c.Context(), req.IDs); err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
return c.Status(fiber.StatusOK).JSON(fiber.Map{
"message": "Tenants deleted successfully",
"count": len(req.IDs),
})
}
func mapTenantSummary(t domain.Tenant) tenantSummary {
domains := make([]string, 0, len(t.Domains))
for _, d := range t.Domains {
domains = append(domains, d.Domain)
}
return tenantSummary{
ID: t.ID,
Type: t.Type,
ParentID: t.ParentID,
Name: t.Name,
Slug: t.Slug,
Description: t.Description,
Status: t.Status,
Domains: domains,
Config: t.Config,
CreatedAt: t.CreatedAt.Format(time.RFC3339),
UpdatedAt: t.UpdatedAt.Format(time.RFC3339),
}
}
func (h *TenantHandler) GetOrgContext(c *fiber.Ctx) error {
if c.Locals("apiKeyName") == nil {
return errorJSON(c, fiber.StatusUnauthorized, "api key authentication is required")
}
if h.Service == nil {
return errorJSON(c, fiber.StatusServiceUnavailable, "tenant service is not configured")
}
allTenants, _, err := h.Service.ListTenants(c.Context(), 10000, 0, "", "")
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
rootSlug := strings.TrimSpace(c.Query("tenantSlug"))
if rootSlug == "" {
rootSlug = "hanmac-family"
}
root, ok := findOrgContextTenantBySlug(allTenants, rootSlug)
if !ok {
return errorJSON(c, fiber.StatusNotFound, "tenant slug not found")
}
scopedTenants := filterOrgContextSubtree(allTenants, root.ID)
contextTenants := make([]orgContextTenant, 0, len(scopedTenants))
tenantIDs := make([]string, 0, len(scopedTenants))
tenantSlugs := make([]string, 0, len(scopedTenants))
tenantByID := make(map[string]orgContextTenant, len(scopedTenants))
tenantBySlug := make(map[string]orgContextTenant, len(scopedTenants))
for _, tenant := range scopedTenants {
summary := mapOrgContextTenant(tenant)
contextTenants = append(contextTenants, summary)
tenantIDs = append(tenantIDs, tenant.ID)
tenantSlugs = append(tenantSlugs, tenant.Slug)
tenantByID[tenant.ID] = summary
tenantBySlug[strings.ToLower(tenant.Slug)] = summary
}
includeUsers := !strings.EqualFold(strings.TrimSpace(c.Query("includeUsers")), "false")
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())
}
}
for i := range contextTenants {
members := membersByTenantID[contextTenants[i].ID]
if members == nil {
members = []orgContextMember{}
}
contextTenants[i].Members = members
tenantByID[contextTenants[i].ID] = contextTenants[i]
tenantBySlug[strings.ToLower(contextTenants[i].Slug)] = contextTenants[i]
}
tree := buildOrgContextTree(root.ID, scopedTenants, tenantByID)
return c.JSON(orgContextResponse{
SchemaVersion: "baron.org-context.v1",
IssuedAt: time.Now().UTC().Format(time.RFC3339),
Scope: orgContextScope{
TenantID: root.ID,
TenantSlug: root.Slug,
},
Tree: tree,
Tenants: contextTenants,
})
}
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{}, "")
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) {
continue
}
assignments := mapOrgContextMemberAssignments(user, tenantByID, tenantBySlug, includeUserIDs)
if len(assignments) == 0 {
continue
}
seen[user.ID] = true
for _, assignment := range assignments {
membersByTenantID[assignment.TenantID] = append(membersByTenantID[assignment.TenantID], assignment.Member)
}
}
return membersByTenantID, nil
}
func findOrgContextTenantBySlug(tenants []domain.Tenant, slug string) (domain.Tenant, bool) {
normalized := strings.ToLower(strings.TrimSpace(slug))
for _, tenant := range tenants {
if strings.ToLower(tenant.Slug) == normalized && isOrgContextTenantType(tenant) {
return tenant, true
}
}
return domain.Tenant{}, false
}
func isOrgContextTenantType(tenant domain.Tenant) bool {
switch strings.ToUpper(tenant.Type) {
case domain.TenantTypeCompanyGroup, domain.TenantTypeCompany, domain.TenantTypeOrganization, domain.TenantTypeUserGroup:
return true
default:
return false
}
}
func filterOrgContextSubtree(tenants []domain.Tenant, rootID string) []domain.Tenant {
descendantIDs := map[string]bool{rootID: true}
frontier := map[string]bool{rootID: true}
for len(frontier) > 0 {
next := map[string]bool{}
for _, tenant := range tenants {
if tenant.ParentID == nil || !frontier[*tenant.ParentID] || descendantIDs[tenant.ID] {
continue
}
descendantIDs[tenant.ID] = true
next[tenant.ID] = true
}
frontier = next
}
excludedIDs := map[string]bool{}
for _, tenant := range tenants {
if descendantIDs[tenant.ID] && tenantVisibility(tenant.Config) == "private" {
excludedIDs[tenant.ID] = true
}
}
changed := true
for changed {
changed = false
for _, tenant := range tenants {
if tenant.ParentID == nil || !descendantIDs[tenant.ID] || excludedIDs[tenant.ID] {
continue
}
if excludedIDs[*tenant.ParentID] {
excludedIDs[tenant.ID] = true
changed = true
}
}
}
filtered := make([]domain.Tenant, 0, len(descendantIDs))
for _, tenant := range tenants {
if descendantIDs[tenant.ID] && !excludedIDs[tenant.ID] && isOrgContextTenantType(tenant) {
filtered = append(filtered, tenant)
}
}
return filtered
}
func mapOrgContextTenant(tenant domain.Tenant) orgContextTenant {
domains := make([]string, 0, len(tenant.Domains))
for _, domain := range tenant.Domains {
domains = append(domains, domain.Domain)
}
visibility, orgUnitType, _ := tenantCSVOrgConfigValues(tenant.Config)
return orgContextTenant{
ID: tenant.ID,
Type: tenant.Type,
Name: tenant.Name,
Slug: tenant.Slug,
ParentID: tenant.ParentID,
Status: tenant.Status,
Description: tenant.Description,
Domains: domains,
Visibility: visibility,
OrgUnitType: orgUnitType,
Config: tenant.Config,
CreatedAt: tenant.CreatedAt.Format(time.RFC3339),
UpdatedAt: tenant.UpdatedAt.Format(time.RFC3339),
Members: []orgContextMember{},
}
}
func mapOrgContextMemberAssignments(user domain.User, tenantByID, tenantBySlug map[string]orgContextTenant, includeUserIDs bool) []orgContextMemberAssignment {
assignments := make([]orgContextMemberAssignment, 0, 2)
seenTenants := map[string]bool{}
appointments := tenantClaimAppointmentsFromTraits(map[string]any(user.Metadata))
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: mapOrgContextMember(user, 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"} {
if tenantSlug := tenantClaimString(appointment, key); tenantSlug != "" {
tenant := tenantBySlug[strings.ToLower(tenantSlug)]
addTenant(tenant, tenant.ID != "", appointment)
}
}
}
if user.TenantID != nil {
addTenant(tenantByID[*user.TenantID], tenantByID[*user.TenantID].ID != "", nil)
}
if user.Tenant != nil {
addTenant(tenantByID[user.Tenant.ID], tenantByID[user.Tenant.ID].ID != "", nil)
tenant := tenantBySlug[strings.ToLower(user.Tenant.Slug)]
addTenant(tenant, tenant.ID != "", nil)
}
return assignments
}
func mapOrgContextMember(user domain.User, appointment map[string]any, includeUserIDs bool) orgContextMember {
grade := user.Grade
position := user.Position
jobTitle := user.JobTitle
department := user.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 = user.ID
phone = user.Phone
}
return orgContextMember{
ID: id,
Email: user.Email,
Name: user.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 {
if tenant.ParentID == nil {
continue
}
childrenByParentID[*tenant.ParentID] = append(childrenByParentID[*tenant.ParentID], tenant)
}
var build func(tenantID string) *orgContextTreeNode
build = func(tenantID string) *orgContextTreeNode {
tenant, ok := tenantByID[tenantID]
if !ok {
return nil
}
node := &orgContextTreeNode{
orgContextTenant: tenant,
Children: []orgContextTreeNode{},
}
for _, child := range childrenByParentID[tenantID] {
childNode := build(child.ID)
if childNode != nil {
node.Children = append(node.Children, *childNode)
}
}
return node
}
return build(rootID)
}
func (h *TenantHandler) countTenantMembersFromProjection(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
}
if len(tenants) == 0 {
return counts, counts, nil
}
if h.UserProjectionRepo == nil {
return nil, nil, errors.New("user projection is not configured")
}
ready, err := h.UserProjectionRepo.IsReady(ctx)
if err != nil {
return nil, nil, fmt.Errorf("user projection status unavailable: %w", err)
}
if !ready {
return nil, nil, errors.New("user projection is not ready")
}
directCounts, err := h.UserProjectionRepo.CountTenantMembers(ctx, tenants)
if err != nil {
return nil, nil, err
}
totalCounts, err := h.UserProjectionRepo.CountTenantMembersRecursive(ctx, tenants)
if err != nil {
return nil, nil, err
}
return directCounts, totalCounts, nil
}
func normalizeTenantStatus(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
if value == "" {
return ""
}
if value != "active" && value != "inactive" {
return ""
}
return value
}
func normalizeTenantType(value string) string {
value = strings.ToUpper(strings.TrimSpace(value))
switch value {
case domain.TenantTypePersonal, domain.TenantTypeCompany, domain.TenantTypeCompanyGroup, domain.TenantTypeOrganization, domain.TenantTypeUserGroup:
return value
default:
return ""
}
}
func (h *TenantHandler) CreateShareLink(c *fiber.Ctx) error {
tenantID := c.Params("id")
var req struct {
Name string `json:"name"`
Description string `json:"description"`
ExpiresAt *time.Time `json:"expiresAt"`
}
if err := c.BodyParser(&req); err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
}
link, err := h.SharedLink.CreateLink(c.Context(), tenantID, req.Name, req.Description, req.ExpiresAt)
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
return c.JSON(link)
}
func (h *TenantHandler) ListShareLinks(c *fiber.Ctx) error {
tenantID := c.Params("id")
links, err := h.SharedLink.GetLinksByTenant(c.Context(), tenantID)
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
return c.JSON(links)
}
func (h *TenantHandler) DeleteShareLink(c *fiber.Ctx) error {
id := c.Params("id")
if err := h.SharedLink.DeactivateLink(c.Context(), id); err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
return c.JSON(fiber.Map{"message": "Share link deleted successfully"})
}
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,
"role", role,
"profile_tenant_id", profileTenantID,
"tenant_header", c.Get("X-Tenant-ID"),
"cache_mode", cacheMode,
)
if cacheMode == "redis" && h.OrgChartCache != nil {
if raw, err := h.OrgChartCache.Get(cacheKey); err == nil && strings.TrimSpace(raw) != "" {
var cached orgChartSnapshotResponse
if err := json.Unmarshal([]byte(raw), &cached); err == nil {
cached.Cache = orgChartSnapshotCacheInfo{
Source: "redis",
Hit: true,
TTLSeconds: int(ttl.Seconds()),
}
c.Set("X-Orgfront-Cache", "HIT")
slog.Info("orgchart snapshot cache hit",
"user_id", userID,
"role", role,
"profile_tenant_id", profileTenantID,
"tenant_header", c.Get("X-Tenant-ID"),
"tenant_count", len(cached.Tenants),
"user_count", len(cached.Users),
)
return c.JSON(cached)
}
slog.Warn("orgchart snapshot cache payload ignored",
"user_id", userID,
"role", role,
"profile_tenant_id", profileTenantID,
"tenant_header", c.Get("X-Tenant-ID"),
"error", err,
)
} else if err != nil && err != redis.Nil {
slog.Warn("orgchart snapshot cache read failed",
"user_id", userID,
"role", role,
"profile_tenant_id", profileTenantID,
"tenant_header", c.Get("X-Tenant-ID"),
"error", err,
)
}
}
snapshot, err := h.buildOrgChartSnapshot(c.Context(), profile)
if err != nil {
slog.Error("orgchart snapshot build failed",
"user_id", userID,
"role", role,
"profile_tenant_id", profileTenantID,
"tenant_header", c.Get("X-Tenant-ID"),
"error", err,
)
return errorJSON(c, fiber.StatusServiceUnavailable, err.Error())
}
snapshot.Cache = orgChartSnapshotCacheInfo{
Source: "database",
Hit: false,
TTLSeconds: int(ttl.Seconds()),
}
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 {
slog.Warn("orgchart snapshot cache write failed",
"user_id", userID,
"role", role,
"profile_tenant_id", profileTenantID,
"tenant_header", c.Get("X-Tenant-ID"),
"error", err,
)
}
}
c.Set("X-Orgfront-Cache", "MISS")
} else {
c.Set("X-Orgfront-Cache", "BYPASS")
}
slog.Info("orgchart snapshot request completed",
"user_id", userID,
"role", role,
"profile_tenant_id", profileTenantID,
"tenant_header", c.Get("X-Tenant-ID"),
"cache_mode", cacheMode,
"cache_result", c.GetRespHeader("X-Orgfront-Cache"),
"tenant_count", len(snapshot.Tenants),
"user_count", len(snapshot.Users),
)
return c.JSON(snapshot)
}
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)
if err != nil {
return orgChartSnapshotResponse{}, err
}
tenantSummaries := make([]tenantSummary, 0, len(tenants))
for _, tenant := range tenants {
summary := mapTenantSummary(tenant)
summary.MemberCount = memberCounts[tenant.ID]
summary.TotalMemberCount = totalMemberCounts[tenant.ID]
tenantSummaries = append(tenantSummaries, summary)
}
users, err := h.listOrgChartUsers(ctx, profile, tenants)
if err != nil {
return orgChartSnapshotResponse{}, err
}
return orgChartSnapshotResponse{
Tenants: tenantSummaries,
Users: users,
}, nil
}
func (h *TenantHandler) listOrgChartTenantsForProfile(ctx context.Context, profile *domain.UserProfileResponse) ([]domain.Tenant, error) {
if h.Service == nil {
return nil, errors.New("tenant service is not configured")
}
role := ""
if profile != nil {
role = domain.NormalizeRole(profile.Role)
}
if role == domain.RoleSuperAdmin {
tenants, _, err := h.Service.ListTenants(ctx, 10000, 0, "", "")
return tenants, err
}
allTenants, _, err := h.Service.ListTenants(ctx, 10000, 0, "", "")
if err != nil {
return nil, err
}
if profile == nil {
return []domain.Tenant{}, nil
}
baseTenantIDs := make([]string, 0, len(profile.ManageableTenants)+len(profile.JoinedTenants)+1)
for _, tenant := range profile.ManageableTenants {
baseTenantIDs = append(baseTenantIDs, tenant.ID)
}
for _, tenant := range profile.JoinedTenants {
baseTenantIDs = append(baseTenantIDs, tenant.ID)
}
if profile.TenantID != nil {
baseTenantIDs = append(baseTenantIDs, *profile.TenantID)
}
parentMap := make(map[string]string)
for _, tenant := range allTenants {
if tenant.ParentID != nil {
parentMap[tenant.ID] = *tenant.ParentID
}
}
roots := make(map[string]bool)
for _, id := range baseTenantIDs {
if strings.TrimSpace(id) != "" {
roots[findTenantRootID(parentMap, id)] = true
}
}
tenants := make([]domain.Tenant, 0, len(allTenants))
for _, tenant := range allTenants {
if roots[findTenantRootID(parentMap, tenant.ID)] {
tenants = append(tenants, tenant)
}
}
return h.filterPrivateTenantsForProfile(ctx, tenants, profile)
}
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{}
if role != domain.RoleSuperAdmin {
tenantIDs = make([]string, 0, len(tenants))
for _, tenant := range tenants {
tenantIDs = append(tenantIDs, tenant.ID)
}
}
users, _, _, err := h.UserRepo.List(ctx, 0, 10000, "", tenantIDs, "")
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)
}
return summaries, nil
}
func orgChartSnapshotCacheKey(profile *domain.UserProfileResponse, tenantHeader string) string {
role := "anonymous"
userID := "anonymous"
tenantID := strings.TrimSpace(tenantHeader)
if profile != nil {
role = domain.NormalizeRole(profile.Role)
userID = strings.TrimSpace(profile.ID)
if tenantID == "" && profile.TenantID != nil {
tenantID = strings.TrimSpace(*profile.TenantID)
}
}
if userID == "" {
userID = "anonymous"
}
if tenantID == "" {
tenantID = "none"
}
return fmt.Sprintf("orgchart:snapshot:v1:%s:%s:%s", role, userID, tenantID)
}
func orgChartProfileLogValues(profile *domain.UserProfileResponse) (string, string, string) {
if profile == nil {
return "anonymous", "anonymous", ""
}
tenantID := ""
if profile.TenantID != nil {
tenantID = strings.TrimSpace(*profile.TenantID)
}
return domain.NormalizeRole(profile.Role), strings.TrimSpace(profile.ID), tenantID
}
func findTenantRootID(parentMap map[string]string, tenantID string) string {
curr := strings.TrimSpace(tenantID)
if curr == "" {
return ""
}
visited := map[string]struct{}{}
for {
parentID := strings.TrimSpace(parentMap[curr])
if parentID == "" || parentID == curr {
return curr
}
if _, exists := visited[parentID]; exists {
return parentID
}
visited[curr] = struct{}{}
curr = parentID
}
}
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 (h *TenantHandler) GetPublicOrgChart(c *fiber.Ctx) error {
token := c.Query("token")
if token == "" {
slog.Warn("public orgchart rejected missing token")
return errorJSON(c, fiber.StatusUnauthorized, "share token is required")
}
link, err := h.SharedLink.ValidateToken(c.Context(), token)
if err != nil {
slog.Warn("public orgchart token validation failed",
"token_length", len(token),
"error", err,
)
return errorJSON(c, fiber.StatusUnauthorized, err.Error())
}
allTenants, _, err := h.Service.ListTenants(c.Context(), 10000, 0, "", "")
if err != nil {
slog.Error("public orgchart tenant list failed",
"link_id", link.ID,
"tenant_id", link.TenantID,
"error", err,
)
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
parentMap := make(map[string]string)
for _, t := range allTenants {
if t.ParentID != nil {
parentMap[t.ID] = *t.ParentID
}
}
sharedRootID := findTenantRootID(parentMap, link.TenantID)
var filteredTenants []domain.Tenant
var tenantIDs []string
for _, t := range allTenants {
if findTenantRootID(parentMap, t.ID) == sharedRootID {
filteredTenants = append(filteredTenants, t)
}
}
filteredTenants = filterPublicTenants(filteredTenants)
for _, t := range filteredTenants {
tenantIDs = append(tenantIDs, t.ID)
}
type publicUserSummary struct {
ID string `json:"id"`
Name string `json:"name"`
Position string `json:"position"`
JobTitle string `json:"jobTitle"`
TenantSlug string `json:"tenantSlug"`
Status string `json:"status"`
}
var publicUsers []publicUserSummary
seen := make(map[string]bool)
// Fetch users by IDs
var usersByID []domain.User
h.DB.Where("tenant_id IN ?", tenantIDs).Preload("Tenant").Find(&usersByID)
for _, u := range usersByID {
if u.Status != "active" || seen[u.ID] {
continue
}
seen[u.ID] = true
tenantSlug := ""
if u.Tenant != nil {
tenantSlug = u.Tenant.Slug
}
publicUsers = append(publicUsers, publicUserSummary{
ID: u.ID, Name: u.Name, Position: u.Position, JobTitle: u.JobTitle, TenantSlug: tenantSlug, Status: u.Status,
})
}
tenantSummaries := make([]tenantSummary, 0, len(filteredTenants))
for _, t := range filteredTenants {
tenantSummaries = append(tenantSummaries, mapTenantSummary(t))
}
slog.Info("public orgchart request completed",
"link_id", link.ID,
"tenant_id", link.TenantID,
"shared_root_id", sharedRootID,
"tenant_count", len(tenantSummaries),
"user_count", len(publicUsers),
)
return c.JSON(fiber.Map{
"tenants": tenantSummaries,
"users": publicUsers,
"sharedWith": link.Name,
})
}