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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user