1
0
forked from baron/baron-sso

테넌트 목록 조회 cursor기반으로 재구성. 사용자 metadata 미사용 필드 제거

This commit is contained in:
2026-05-13 18:05:51 +09:00
parent a4d707d4d8
commit 5e7b7b878c
85 changed files with 4808 additions and 734 deletions

View File

@@ -4,6 +4,7 @@ import (
"baron-sso-backend/internal/domain"
"context"
"errors"
"strconv"
"strings"
"time"
@@ -33,7 +34,19 @@ func NewTenantRepository(db *gorm.DB) TenantRepository {
}
func (r *tenantRepository) Create(ctx context.Context, tenant *domain.Tenant) error {
return r.db.WithContext(ctx).Create(tenant).Error
tenant.Slug = strings.ToLower(strings.TrimSpace(tenant.Slug))
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if tenant.Slug != "" {
suffix := "-deleted-" + strconv.FormatInt(time.Now().UTC().UnixNano(), 10)
if err := tx.Unscoped().
Model(&domain.Tenant{}).
Where("slug = ? AND deleted_at IS NOT NULL", tenant.Slug).
Update("slug", gorm.Expr("slug || ?", suffix)).Error; err != nil {
return err
}
}
return tx.Create(tenant).Error
})
}
func (r *tenantRepository) Update(ctx context.Context, tenant *domain.Tenant) error {
@@ -124,7 +137,7 @@ func (r *tenantRepository) List(ctx context.Context, limit, offset int, parentID
return nil, 0, err
}
if err := db.Order("created_at desc").Limit(limit).Offset(offset).Preload("Domains").Find(&tenants).Error; err != nil {
if err := db.Order("created_at desc, id desc").Limit(limit).Offset(offset).Preload("Domains").Find(&tenants).Error; err != nil {
return nil, 0, err
}

View File

@@ -6,6 +6,7 @@ import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTenantRepository(t *testing.T) {
@@ -161,4 +162,31 @@ func TestTenantRepository(t *testing.T) {
err = repo.Create(ctx, tenant2)
assert.Error(t, err) // Should fail due to UNIQUE constraint
})
t.Run("Create reuses slug held by legacy soft-deleted tenant", func(t *testing.T) {
slug := "legacy-soft-delete-reuse"
require.NoError(t, testDB.Unscoped().Where("slug = ?", slug).Delete(&domain.Tenant{}).Error)
legacy := &domain.Tenant{
Name: "Legacy Deleted",
Slug: slug,
Type: domain.TenantTypeCompany,
}
require.NoError(t, repo.Create(ctx, legacy))
require.NoError(t, testDB.Delete(&domain.Tenant{}, "id = ?", legacy.ID).Error)
_, err := repo.FindBySlug(ctx, slug)
require.Error(t, err)
replacement := &domain.Tenant{
Name: "Replacement",
Slug: slug,
Type: domain.TenantTypeCompany,
}
require.NoError(t, repo.Create(ctx, replacement))
found, err := repo.FindBySlug(ctx, slug)
require.NoError(t, err)
assert.Equal(t, replacement.ID, found.ID)
})
}

View File

@@ -0,0 +1,56 @@
package repository
import (
"context"
"gorm.io/gorm"
)
func ClearOrphanUserTenantMemberships(ctx context.Context, db *gorm.DB) (int64, error) {
result := db.WithContext(ctx).Exec(`
WITH orphan_users AS (
SELECT u.id
FROM users AS u
WHERE u.deleted_at IS NULL
AND (
(
u.tenant_id IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM tenants AS t
WHERE t.id = u.tenant_id
AND t.deleted_at IS NULL
)
)
OR (
NULLIF(BTRIM(u.company_code), '') IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM tenants AS t
WHERE LOWER(t.slug) = LOWER(BTRIM(u.company_code))
AND t.deleted_at IS NULL
)
)
OR EXISTS (
SELECT 1
FROM UNNEST(COALESCE(u.company_codes, ARRAY[]::text[])) AS code(value)
WHERE NULLIF(BTRIM(code.value), '') IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM tenants AS t
WHERE LOWER(t.slug) = LOWER(BTRIM(code.value))
AND t.deleted_at IS NULL
)
)
)
)
UPDATE users AS u
SET tenant_id = NULL,
company_code = '',
company_codes = NULL,
updated_at = NOW()
FROM orphan_users AS ou
WHERE u.id = ou.id
`)
return result.RowsAffected, result.Error
}

View File

@@ -0,0 +1,63 @@
package repository
import (
"baron-sso-backend/internal/domain"
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestClearOrphanUserTenantMemberships(t *testing.T) {
ctx := context.Background()
repo := NewUserRepository(testDB)
tenantRepo := NewTenantRepository(testDB)
require.NoError(t, testDB.Exec("DELETE FROM user_login_ids").Error)
require.NoError(t, testDB.Exec("DELETE FROM users").Error)
require.NoError(t, testDB.Exec("DELETE FROM tenant_domains").Error)
require.NoError(t, testDB.Unscoped().Where("slug IN ?", []string{"orphan-active", "orphan-deleted"}).Delete(&domain.Tenant{}).Error)
activeTenant := &domain.Tenant{Name: "Active Tenant", Slug: "orphan-active", Type: domain.TenantTypeCompany}
deletedTenant := &domain.Tenant{Name: "Deleted Tenant", Slug: "orphan-deleted", Type: domain.TenantTypeCompany}
require.NoError(t, tenantRepo.Create(ctx, activeTenant))
require.NoError(t, tenantRepo.Create(ctx, deletedTenant))
require.NoError(t, testDB.Delete(&domain.Tenant{}, "id = ?", deletedTenant.ID).Error)
activeUser := &domain.User{
Email: "active-membership@example.com",
Name: "Active Membership",
Role: "user",
TenantID: &activeTenant.ID,
CompanyCode: activeTenant.Slug,
CompanyCodes: []string{activeTenant.Slug},
}
orphanUser := &domain.User{
Email: "orphan-membership@example.com",
Name: "Orphan Membership",
Role: "user",
TenantID: &deletedTenant.ID,
CompanyCode: deletedTenant.Slug,
CompanyCodes: []string{deletedTenant.Slug},
}
require.NoError(t, repo.Create(ctx, activeUser))
require.NoError(t, repo.Create(ctx, orphanUser))
affected, err := ClearOrphanUserTenantMemberships(ctx, testDB)
require.NoError(t, err)
assert.Equal(t, int64(1), affected)
foundActive, err := repo.FindByEmail(ctx, activeUser.Email)
require.NoError(t, err)
require.NotNil(t, foundActive.TenantID)
assert.Equal(t, activeTenant.ID, *foundActive.TenantID)
assert.Equal(t, activeTenant.Slug, foundActive.CompanyCode)
assert.Equal(t, []string{activeTenant.Slug}, []string(foundActive.CompanyCodes))
foundOrphan, err := repo.FindByEmail(ctx, orphanUser.Email)
require.NoError(t, err)
assert.Nil(t, foundOrphan.TenantID)
assert.Empty(t, foundOrphan.CompanyCode)
assert.Empty(t, foundOrphan.CompanyCodes)
}

View File

@@ -17,7 +17,7 @@ type UserRepository interface {
FindByID(ctx context.Context, id string) (*domain.User, error)
FindByIDs(ctx context.Context, ids []string) ([]domain.User, error)
ListByTenant(ctx context.Context, tenantID string) ([]domain.User, error)
List(ctx context.Context, offset, limit int, search string, companyCode string) ([]domain.User, int64, error)
List(ctx context.Context, offset, limit int, search string, tenantSlug string) ([]domain.User, int64, error)
CountByTenant(ctx context.Context, tenantID string) (int64, error)
CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error)
CountByCompanyCodes(ctx context.Context, codes []string) (map[string]int64, error)
@@ -200,15 +200,14 @@ func lowerStrings(arr []string) []string {
return res
}
func (r *userRepository) List(ctx context.Context, offset, limit int, search string, companyCode string) ([]domain.User, int64, error) {
func (r *userRepository) List(ctx context.Context, offset, limit int, search string, tenantSlug string) ([]domain.User, int64, error) {
var users []domain.User
var total int64
db := r.db.WithContext(ctx).Model(&domain.User{})
if companyCode != "" {
// [Matrix Fix] Match users either by their primary company code OR by being in the company_codes array OR by tenant slug
if tenantSlug != "" {
db = db.Joins("LEFT JOIN tenants ON users.tenant_id = tenants.id").
Where("users.company_code = ? OR ? = ANY(users.company_codes) OR tenants.slug = ?", companyCode, companyCode, companyCode)
Where("users.company_code = ? OR ? = ANY(users.company_codes) OR tenants.slug = ?", tenantSlug, tenantSlug, tenantSlug)
}
if search != "" {