forked from baron/baron-sso
테넌트 목록 조회 cursor기반으로 재구성. 사용자 metadata 미사용 필드 제거
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
56
backend/internal/repository/user_membership_maintenance.go
Normal file
56
backend/internal/repository/user_membership_maintenance.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 != "" {
|
||||
|
||||
Reference in New Issue
Block a user