package handler import ( "baron-sso-backend/internal/bootstrap" "baron-sso-backend/internal/domain" "baron-sso-backend/internal/repository" "baron-sso-backend/internal/service" "baron-sso-backend/internal/utils" "bytes" "context" "encoding/csv" "errors" "fmt" "io" "strings" "time" "github.com/gofiber/fiber/v2" "gorm.io/gorm" ) type TenantHandler struct { DB *gorm.DB Service service.TenantService UserRepo repository.UserRepository UserProjectionRepo repository.UserProjectionRepository Keto service.KetoService KetoOutbox repository.KetoOutboxRepository KratosAdmin service.KratosAdminService SharedLink service.SharedLinkService Worksmobile service.WorksmobileSyncer } 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) *TenantHandler { return &TenantHandler{ DB: db, Service: svc, UserRepo: userRepo, UserProjectionRepo: userProjectionRepo, Keto: keto, KetoOutbox: outbox, KratosAdmin: kratos, SharedLink: sharedLink, } } 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"` 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"` } type tenantImportResult struct { Created int `json:"created"` Updated int `json:"updated"` Failed int `json:"failed"` Errors []string `json:"errors"` } 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 } 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") if limit <= 0 { limit = 50 } if offset < 0 { offset = 0 } var tenants []domain.Tenant var total int64 var err error 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) } // Try to find by companyCode if needed if profile.CompanyCode != "" { for _, t := range allTenants { if strings.EqualFold(t.Slug, profile.CompanyCode) { baseTenantIDs = append(baseTenantIDs, t.ID) } } } parentMap := make(map[string]string) for _, t := range allTenants { if t.ParentID != nil { parentMap[t.ID] = *t.ParentID } } findRoot := func(id string) string { curr := id for { p, exists := parentMap[curr] if !exists || p == "" { break } curr = p } return curr } roots := make(map[string]bool) for _, id := range baseTenantIDs { roots[findRoot(id)] = true } // Filter tenants that belong to the same tree family for _, t := range allTenants { if roots[findRoot(t.ID)] { tenants = append(tenants, t) } } } total = int64(len(tenants)) if offset < len(tenants) { end := offset + limit if end > len(tenants) { end = len(tenants) } tenants = tenants[offset:end] } else { tenants = []domain.Tenant{} } } else { // Super Admin case 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()}) } } memberCounts, 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] items = append(items, summary) } return c.JSON(tenantListResponse{Items: items, Limit: limit, Offset: offset, Total: total}) } func (h *TenantHandler) ExportTenantsCSV(c *fiber.Ctx) error { tenants, _, err := h.Service.ListTenants(c.Context(), 10000, 0, "") if err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } 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"}); err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } } else if err := writer.Write([]string{"name", "type", "parent_tenant_slug", "slug", "memo", "email_domain"}); err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } slugByID := make(map[string]string, len(tenants)) for _, tenant := range tenants { 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) } } row := []string{ tenant.Name, tenant.Type, parentSlug, tenant.Slug, tenant.Description, strings.Join(domains, ";"), } if includeIDs { row = []string{ tenant.ID, tenant.Name, tenant.Type, parentID, parentSlug, tenant.Slug, tenant.Description, strings.Join(domains, ";"), } } 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 (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)} for i, record := range records { rowNumber := i + 2 if record.ParentTenantID == nil && record.ParentTenantSlug != "" { parentID := tenantIDBySlug[strings.ToLower(record.ParentTenantSlug)] if parentID == "" { result.Failed++ result.Errors = append(result.Errors, fmt.Sprintf("row %d: parent tenant slug not found: %s", rowNumber, record.ParentTenantSlug)) continue } record.ParentTenantID = &parentID } if record.TenantID != "" || (h.DB != nil && record.Slug != "") { tenant, updated, err := h.upsertTenantCSVRecord(c, record) if err != nil { result.Failed++ result.Errors = append(result.Errors, fmt.Sprintf("row %d: %s", rowNumber, err.Error())) continue } if updated { tenantIDBySlug[strings.ToLower(record.Slug)] = tenant.ID result.Updated++ if h.Worksmobile != nil { _ = h.Worksmobile.EnqueueTenantUpsertIfInScope(c.Context(), *tenant) } continue } } recordCreatorID := creatorID if record.Type == domain.TenantTypeOrganization { recordCreatorID = "" } tenant, err := h.createTenantCSVRecord(c, record, recordCreatorID) if err != nil { result.Failed++ result.Errors = append(result.Errors, fmt.Sprintf("row %d: %s", rowNumber, err.Error())) continue } if tenant == nil { result.Failed++ result.Errors = append(result.Errors, fmt.Sprintf("row %d: tenant creation returned empty result", rowNumber)) continue } tenantIDBySlug[strings.ToLower(record.Slug)] = tenant.ID result.Created++ if h.Worksmobile != nil { _ = h.Worksmobile.EnqueueTenantUpsertIfInScope(c.Context(), *tenant) } } 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 } 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")), }) } 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", } 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 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)) 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, fmt.Errorf("orgUnitType must be one of 실, 팀, 디비전, 셀, 본부, 지역본부, 부") } orgUnitType = strings.TrimSpace(orgUnitType) if orgUnitType == "" { continue } if !isAllowedOrgUnitType(orgUnitType) { return nil, fmt.Errorf("orgUnitType must be one of 실, 팀, 디비전, 셀, 본부, 지역본부, 부") } normalized[key] = orgUnitType continue } normalized[key] = value } return normalized, nil } func isAllowedOrgUnitType(value string) bool { switch value { case "실", "팀", "디비전", "셀", "본부", "지역본부", "부": 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 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 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, bool, error) { if h.DB == nil { if record.TenantID != "" { return nil, false, errors.New("database not available for tenant update") } return nil, false, 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, false, nil } if err != nil { return nil, false, err } tenant.Name = record.Name tenant.Type = record.Type tenant.ParentID = record.ParentTenantID tenant.Slug = record.Slug tenant.Description = record.Memo if tenant.Status == "" { tenant.Status = domain.TenantStatusActive } if err := h.DB.Save(&tenant).Error; err != nil { return nil, false, err } if err := h.DB.Delete(&domain.TenantDomain{}, "tenant_id = ?", tenant.ID).Error; err != nil { return nil, false, 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, false, err } } return &tenant, true, 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, } 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) 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, 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] 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 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) } // 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 := 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) countTenantMembersFromProjection(ctx context.Context, tenants []domain.Tenant) (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, nil } if h.UserProjectionRepo == nil { return nil, errors.New("user projection is not configured") } ready, err := h.UserProjectionRepo.IsReady(ctx) if err != nil { return nil, fmt.Errorf("user projection status unavailable: %w", err) } if !ready { return nil, errors.New("user projection is not ready") } return h.UserProjectionRepo.CountTenantMembers(ctx, tenants) } 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) GetPublicOrgChart(c *fiber.Ctx) error { token := c.Query("token") if token == "" { return errorJSON(c, fiber.StatusUnauthorized, "share token is required") } link, err := h.SharedLink.ValidateToken(c.Context(), token) if err != nil { return errorJSON(c, fiber.StatusUnauthorized, err.Error()) } allTenants, _, err := h.Service.ListTenants(c.Context(), 10000, 0, "") if err != nil { 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 } } findRoot := func(id string) string { curr := id for { p, exists := parentMap[curr] if !exists || p == "" { break } curr = p } return curr } sharedRootID := findRoot(link.TenantID) var filteredTenants []domain.Tenant var tenantIDs []string var slugs []string for _, t := range allTenants { if findRoot(t.ID) == sharedRootID { filteredTenants = append(filteredTenants, t) } } filteredTenants = filterPublicTenants(filteredTenants) for _, t := range filteredTenants { tenantIDs = append(tenantIDs, t.ID) slugs = append(slugs, t.Slug) } type publicUserSummary struct { ID string `json:"id"` Name string `json:"name"` Position string `json:"position"` JobTitle string `json:"jobTitle"` CompanyCode string `json:"companyCode"` 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 cc := u.CompanyCode if cc == "" && u.Tenant != nil { cc = u.Tenant.Slug } publicUsers = append(publicUsers, publicUserSummary{ ID: u.ID, Name: u.Name, Position: u.Position, JobTitle: u.JobTitle, CompanyCode: cc, Status: u.Status, }) } // Fetch users by Slugs var usersBySlug []domain.User h.DB.Where("company_code IN ?", slugs).Preload("Tenant").Find(&usersBySlug) for _, u := range usersBySlug { if u.Status != "active" || seen[u.ID] { continue } seen[u.ID] = true cc := u.CompanyCode if cc == "" && u.Tenant != nil { cc = u.Tenant.Slug } publicUsers = append(publicUsers, publicUserSummary{ ID: u.ID, Name: u.Name, Position: u.Position, JobTitle: u.JobTitle, CompanyCode: cc, Status: u.Status, }) } tenantSummaries := make([]tenantSummary, 0, len(filteredTenants)) for _, t := range filteredTenants { tenantSummaries = append(tenantSummaries, mapTenantSummary(t)) } return c.JSON(fiber.Map{ "tenants": tenantSummaries, "users": publicUsers, "sharedWith": link.Name, }) }