1
0
forked from baron/baron-sso

feat(user): support fixed UUID registration and enhance bulk import results

- Added support for fixed UUIDs during bulk registration (Search-first + ExternalID mapping)
- Implemented idempotency and visibility restoration for soft-deleted users
- Enhanced bulk upload UI to show 'New/Updated/Unchanged' status and modified fields
- Added logic to reclaim identifiers (login_id) from colliding records
- Added frontend E2E and backend unit tests for UUID integrity and conflict handling
- Fixed i18n, formatting, and mock tests to satisfy code-check
- Applied 'go fix' for 'omitzero' tags and general Go standards
This commit is contained in:
2026-06-01 15:34:08 +09:00
parent 4a1e89e421
commit 31d107ff2e
85 changed files with 2104 additions and 1149 deletions

View File

@@ -44,7 +44,7 @@ func (r *clientConsentRepo) Find(ctx context.Context, clientID, subject string)
func (r *clientConsentRepo) Upsert(ctx context.Context, consent *domain.ClientConsent) error {
return r.db.WithContext(ctx).Unscoped().
Where("client_id = ? AND subject = ?", consent.ClientID, consent.Subject).
Assign(map[string]interface{}{
Assign(map[string]any{
"granted_scopes": consent.GrantedScopes,
"updated_at": gorm.Expr("NOW()"),
"deleted_at": nil,

View File

@@ -70,7 +70,7 @@ func (r *ketoOutboxRepository) ListCurrentBySubject(ctx context.Context, namespa
}
func (r *ketoOutboxRepository) UpdateStatus(ctx context.Context, id string, status string, retryCount int, lastError string) error {
return r.db.WithContext(ctx).Model(&domain.KetoOutbox{}).Where("id = ?", id).Updates(map[string]interface{}{
return r.db.WithContext(ctx).Model(&domain.KetoOutbox{}).Where("id = ?", id).Updates(map[string]any{
"status": status,
"retry_count": retryCount,
"last_error": lastError,
@@ -80,7 +80,7 @@ func (r *ketoOutboxRepository) UpdateStatus(ctx context.Context, id string, stat
func (r *ketoOutboxRepository) MarkProcessed(ctx context.Context, id string) error {
now := time.Now()
return r.db.WithContext(ctx).Model(&domain.KetoOutbox{}).Where("id = ?", id).Updates(map[string]interface{}{
return r.db.WithContext(ctx).Model(&domain.KetoOutbox{}).Where("id = ?", id).Updates(map[string]any{
"status": domain.KetoOutboxStatusProcessed,
"processed_at": &now,
"updated_at": now,

View File

@@ -76,7 +76,7 @@ func (r *userProjectionRepository) CountTenantMembers(ctx context.Context, tenan
}
valuePlaceholders := make([]string, 0, len(tenants))
args := make([]interface{}, 0, len(tenants)*2)
args := make([]any, 0, len(tenants)*2)
for _, tenant := range tenants {
valuePlaceholders = append(valuePlaceholders, "(?, ?)")
args = append(args, strings.TrimSpace(tenant.ID), strings.TrimSpace(tenant.Slug))
@@ -124,6 +124,14 @@ func (r *userProjectionRepository) ReplaceAllFromKratos(ctx context.Context, use
}
if len(users) > 0 {
// [FIX] Handle email conflicts before bulk upsert
for _, u := range users {
if u.Email != "" {
// Hard-delete any record with same email but different ID to clear unique constraint
_ = tx.Unscoped().Where("email = ? AND id != ?", u.Email, u.ID).Delete(&domain.User{}).Error
}
}
if err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "id"}},
UpdateAll: true,

View File

@@ -24,6 +24,7 @@ type UserRepository interface {
FindByTenantIDs(ctx context.Context, tenantIDs []string) ([]domain.User, error)
FindByCompanyCodes(ctx context.Context, codes []string) ([]domain.User, error)
Delete(ctx context.Context, id string) error
DB() *gorm.DB
// Multiple identifiers support
UpdateUserLoginIDs(ctx context.Context, userID string, loginIDs []domain.UserLoginID) error
@@ -40,18 +41,27 @@ func NewUserRepository(db *gorm.DB) UserRepository {
return &userRepository{db: db}
}
func (r *userRepository) DB() *gorm.DB {
return r.db
}
func (r *userRepository) Create(ctx context.Context, user *domain.User) error {
return r.db.WithContext(ctx).Create(user).Error
}
func (r *userRepository) Update(ctx context.Context, user *domain.User) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 1. Check for email collision (including soft-deleted)
var existing domain.User
if err := tx.Unscoped().Where("email = ?", user.Email).First(&existing).Error; err == nil {
// If email exists but ID is different, we MUST clear the old one to avoid unique constraint violation
if existing.ID != user.ID {
// [Restored] Check if the existing user is archived
if strings.EqualFold(strings.TrimSpace(existing.Status), domain.UserStatusArchived) {
return fmt.Errorf("email is reserved by archived user: %s", user.Email)
}
// HARD DELETE the old record and its associated login IDs to free up the email and identifiers
if err := tx.Unscoped().Where("user_id = ?", existing.ID).Delete(&domain.UserLoginID{}).Error; err != nil {
return err
}
@@ -61,9 +71,25 @@ func (r *userRepository) Update(ctx context.Context, user *domain.User) error {
}
}
return tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "id"}},
UpdateAll: true,
// 2. Perform Upsert on the new/target ID
return tx.Unscoped().Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "id"}},
DoUpdates: clause.Assignments(map[string]any{
"email": user.Email,
"name": user.Name,
"phone": user.Phone,
"role": user.Role,
"status": user.Status,
"department": user.Department,
"grade": user.Grade,
"position": user.Position,
"job_title": user.JobTitle,
"metadata": user.Metadata,
"tenant_id": user.TenantID,
"affiliation_type": user.AffiliationType,
"updated_at": user.UpdatedAt,
"deleted_at": nil, // Ensure it's active
}),
}).Create(user).Error
})
}
@@ -222,8 +248,9 @@ func (r *userRepository) Delete(ctx context.Context, id string) error {
func (r *userRepository) UpdateUserLoginIDs(ctx context.Context, userID string, loginIDs []domain.UserLoginID) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// Delete existing login IDs for this user
if err := tx.Where("user_id = ?", userID).Delete(&domain.UserLoginID{}).Error; err != nil {
// [FIX] Use Unscoped to permanently delete existing login IDs for this user
// This prevents unique constraint violations with soft-deleted records
if err := tx.Unscoped().Where("user_id = ?", userID).Delete(&domain.UserLoginID{}).Error; err != nil {
return err
}