package handler import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/repository" "baron-sso-backend/internal/service" "errors" "strings" "time" "github.com/gofiber/fiber/v2" "gorm.io/gorm" ) type TenantHandler struct { DB *gorm.DB Service service.TenantService Keto service.KetoService KratosAdmin *service.KratosAdminService } func NewTenantHandler(db *gorm.DB, svc service.TenantService, keto service.KetoService, kratos *service.KratosAdminService) *TenantHandler { return &TenantHandler{ DB: db, Service: svc, Keto: keto, KratosAdmin: kratos, } } type tenantSummary struct { ID string `json:"id"` 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"` 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"` } 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 c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) } // Basic validation if req.Name == "" || req.Domain == "" || req.AdminEmail == "" { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "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 c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": 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 c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenant id is required"}) } if err := h.Service.ApproveTenant(c.Context(), tenantID); err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } return c.JSON(fiber.Map{"message": "Tenant approved successfully"}) } func (h *TenantHandler) ListTenants(c *fiber.Ctx) error { if h.DB == nil { return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "database not available"}) } limit := c.QueryInt("limit", 50) offset := c.QueryInt("offset", 0) if limit <= 0 { limit = 50 } if offset < 0 { offset = 0 } var total int64 if err := h.DB.Model(&domain.Tenant{}).Count(&total).Error; err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } var tenants []domain.Tenant if err := h.DB.Order("created_at desc").Limit(limit).Offset(offset).Preload("Domains").Find(&tenants).Error; err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } items := make([]tenantSummary, 0, len(tenants)) for _, t := range tenants { items = append(items, mapTenantSummary(t)) } return c.JSON(tenantListResponse{Items: items, Limit: limit, Offset: offset, Total: total}) } func (h *TenantHandler) GetTenant(c *fiber.Ctx) error { if h.DB == nil { return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "database not available"}) } tenantID := strings.TrimSpace(c.Params("id")) if tenantID == "" { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "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 c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "tenant not found"}) } return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } return c.JSON(mapTenantSummary(tenant)) } func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error { if h.DB == nil { return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "database not available"}) } var req struct { Name string `json:"name"` Slug string `json:"slug"` Description string `json:"description"` Status string `json:"status"` Domains []string `json:"domains"` Config map[string]any `json:"config"` } if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) } name := strings.TrimSpace(req.Name) if name == "" { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "name is required"}) } slug := normalizeTenantSlug(req.Slug) if slug == "" { slug = normalizeTenantSlug(name) } if slug == "" { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "slug is required"}) } status := normalizeTenantStatus(req.Status) if status == "" { status = "active" } // Use Service tenant, err := h.Service.RegisterTenant(c.Context(), name, slug, req.Description, req.Domains) if err != nil { if strings.Contains(err.Error(), "already exists") { return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": err.Error()}) } return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } if req.Config != nil { tenant.Config = req.Config h.DB.Save(tenant) } return c.Status(fiber.StatusCreated).JSON(mapTenantSummary(*tenant)) } func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error { if h.DB == nil { return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "database not available"}) } tenantID := strings.TrimSpace(c.Params("id")) if tenantID == "" { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "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 c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "tenant not found"}) } return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } var req struct { Name *string `json:"name"` Slug *string `json:"slug"` Description *string `json:"description"` Status *string `json:"status"` Domains []string `json:"domains"` Config map[string]any `json:"config"` } if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) } if req.Name != nil { name := strings.TrimSpace(*req.Name) if name == "" { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "name cannot be empty"}) } tenant.Name = name } if req.Slug != nil { slug := normalizeTenantSlug(*req.Slug) if slug == "" { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "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 c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "slug already exists"}) } else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": 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 c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "status must be active or inactive"}) } tenant.Status = status } if req.Config != nil { tenant.Config = req.Config } if err := h.DB.Save(&tenant).Error; err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } // Update domains if provided if req.Domains != nil { // Simple approach: Delete existing and recreate if err := h.DB.Delete(&domain.TenantDomain{}, "tenant_id = ?", tenant.ID).Error; err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to clear old domains"}) } for _, d := range req.Domains { if strings.TrimSpace(d) == "" { continue } // Use repository for consistency if err := repository.NewTenantRepository(h.DB).AddDomain(c.Context(), tenant.ID, strings.TrimSpace(d), true); err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to add domain: " + d}) } } } // Refetch to get updated relations h.DB.Preload("Domains").First(&tenant, "id = ?", tenant.ID) return c.JSON(mapTenantSummary(tenant)) } func (h *TenantHandler) DeleteTenant(c *fiber.Ctx) error { if h.DB == nil { return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "database not available"}) } tenantID := strings.TrimSpace(c.Params("id")) if tenantID == "" { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "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 c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "tenant not found"}) } return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": 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 c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to release slug"}) } if err := h.DB.Delete(&tenant).Error; err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } return c.SendStatus(fiber.StatusNoContent) } func (h *TenantHandler) ListAdmins(c *fiber.Ctx) error { tenantID := c.Params("id") if tenantID == "" { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenant id is required"}) } // Fetch admins from Keto relations, err := h.Keto.ListRelations(c.Context(), "Tenant", tenantID, "admin", "") if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": 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 from Kratos identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID) if err != nil { admins = append(admins, adminInfo{ID: userID, Name: "Unknown", Email: "Unknown"}) continue } name := "" if n, ok := identity.Traits["name"].(string); ok { name = n } email := "" if e, ok := identity.Traits["email"].(string); ok { email = e } 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 c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenantId and userId are required"}) } if err := h.Keto.CreateRelation(c.Context(), "Tenant", tenantID, "admin", "User:"+userID); err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } 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 c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenantId and userId are required"}) } if err := h.Keto.DeleteRelation(c.Context(), "Tenant", tenantID, "admin", "User:"+userID); err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } return c.SendStatus(fiber.StatusNoContent) } 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, 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 normalizeTenantSlug(value string) string { value = strings.ToLower(strings.TrimSpace(value)) value = strings.ReplaceAll(value, " ", "-") var b strings.Builder for _, r := range value { if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' { b.WriteRune(r) } } return strings.Trim(b.String(), "-") } func normalizeTenantStatus(value string) string { value = strings.ToLower(strings.TrimSpace(value)) if value == "" { return "" } if value != "active" && value != "inactive" { return "" } return value }