1
0
forked from baron/baron-sso

org chart 연동기능 추가

This commit is contained in:
2026-04-29 21:00:51 +09:00
parent 438f844f2b
commit 01e7b15c46
25 changed files with 5141 additions and 2940 deletions

View File

@@ -5,7 +5,11 @@ import (
"baron-sso-backend/internal/repository"
"baron-sso-backend/internal/service"
"baron-sso-backend/internal/utils"
"bytes"
"encoding/csv"
"errors"
"fmt"
"io"
"strings"
"time"
@@ -57,6 +61,23 @@ type tenantListResponse struct {
Total int64 `json:"total"`
}
type tenantImportResult struct {
Created int `json:"created"`
Updated int `json:"updated"`
Failed int `json:"failed"`
Errors []string `json:"errors"`
}
type tenantCSVRecord struct {
TenantID string
Name string
Type string
ParentTenantID *string
Slug string
Memo string
Domains []string
}
func (h *TenantHandler) RegisterTenantPublic(c *fiber.Ctx) error {
var req struct {
Name string `json:"name"`
@@ -229,6 +250,361 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
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)
if err := writer.Write([]string{"tenant_id", "name", "type", "parent_tenant_id", "slug", "memo", "email_domain"}); err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
for _, tenant := range tenants {
parentID := ""
if tenant.ParentID != nil {
parentID = *tenant.ParentID
}
domains := make([]string, 0, len(tenant.Domains))
for _, domainName := range tenant.Domains {
domainName := strings.TrimSpace(domainName.Domain)
if domainName != "" {
domains = append(domains, domainName)
}
}
if err := writer.Write([]string{
tenant.ID,
tenant.Name,
tenant.Type,
parentID,
tenant.Slug,
tenant.Description,
strings.Join(domains, ";"),
}); 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())
}
creatorID := ""
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil {
creatorID = profile.ID
}
result := tenantImportResult{Errors: make([]string, 0)}
for i, record := range records {
rowNumber := i + 2
if record.TenantID != "" || (h.DB != nil && record.Slug != "") {
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 {
result.Updated++
continue
}
}
if err := h.createTenantCSVRecord(c, record, creatorID); err != nil {
result.Failed++
result.Errors = append(result.Errors, fmt.Sprintf("row %d: %s", rowNumber, err.Error()))
continue
}
result.Created++
}
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,
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",
"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 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 (h *TenantHandler) upsertTenantCSVRecord(c *fiber.Ctx, record tenantCSVRecord) (bool, error) {
if h.DB == nil {
if record.TenantID != "" {
return false, errors.New("database not available for tenant update")
}
return 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 false, nil
}
if err != nil {
return 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 false, err
}
if err := h.DB.Delete(&domain.TenantDomain{}, "tenant_id = ?", tenant.ID).Error; err != nil {
return 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 false, err
}
}
return true, nil
}
func (h *TenantHandler) createTenantCSVRecord(c *fiber.Ctx, record tenantCSVRecord, creatorID string) 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 err
}
if exists > 0 {
return 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 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 err
}
}
return nil
}
_, err := h.Service.RegisterTenant(c.Context(), record.Name, record.Slug, record.Type, record.Memo, record.Domains, record.ParentTenantID, creatorID)
return err
}
func (h *TenantHandler) GetTenant(c *fiber.Ctx) error {
if h.DB == nil {
return errorJSON(c, fiber.StatusServiceUnavailable, "database not available")