package service import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/repository" "context" "fmt" "strings" "time" ) type UserProjectionSyncService struct { kratos KratosAdminService repo repository.UserProjectionRepository } type UserProjectionReconciler interface { Reconcile(ctx context.Context) (int, error) } func NewUserProjectionSyncService(kratos KratosAdminService, repo repository.UserProjectionRepository) *UserProjectionSyncService { return &UserProjectionSyncService{ kratos: kratos, repo: repo, } } func (s *UserProjectionSyncService) Reconcile(ctx context.Context) (int, error) { if s == nil || s.kratos == nil || s.repo == nil { return 0, fmt.Errorf("user projection sync dependencies are not configured") } identities, err := s.kratos.ListIdentities(ctx) if err != nil { _ = s.repo.MarkFailed(ctx, err) return 0, err } users := make([]domain.User, 0, len(identities)) for _, identity := range identities { users = append(users, MapKratosIdentityToLocalUser(identity)) } if err := s.repo.ReplaceAllFromKratos(ctx, users); err != nil { _ = s.repo.MarkFailed(ctx, err) return 0, err } return len(users), nil } func MapKratosIdentityToLocalUser(identity KratosIdentity) domain.User { traits := identity.Traits now := time.Now() createdAt := identity.CreatedAt if createdAt.IsZero() { createdAt = now } updatedAt := identity.UpdatedAt if updatedAt.IsZero() { updatedAt = now } role, ok := domain.NormalizeRoleAlias(kratosProjectionTraitString(traits, "role")) if !ok { role, ok = domain.NormalizeRoleAlias(kratosProjectionTraitString(traits, "grade")) if !ok { role = domain.RoleUser } } grade := kratosProjectionTraitString(traits, "grade") if _, ok := domain.NormalizeRoleAlias(grade); ok { grade = "" } user := domain.User{ ID: identity.ID, Email: kratosProjectionTraitString(traits, "email"), Name: kratosProjectionTraitString(traits, "name"), Phone: kratosProjectionTraitString(traits, "phone_number"), Role: role, Status: normalizeProjectionStatus(identity.State), Department: kratosProjectionTraitString(traits, "department"), Grade: grade, Position: kratosProjectionTraitString(traits, "position"), JobTitle: kratosProjectionTraitString(traits, "jobTitle"), AffiliationType: kratosProjectionTraitString(traits, "affiliationType"), CreatedAt: createdAt, UpdatedAt: updatedAt, Metadata: make(domain.JSONMap), } if tenantID := kratosProjectionTraitString(traits, "tenant_id"); tenantID != "" { user.TenantID = &tenantID } if relyingPartyID := kratosProjectionTraitString(traits, "relying_party_id"); relyingPartyID != "" { user.RelyingPartyID = &relyingPartyID } coreTraits := map[string]bool{ "email": true, "name": true, "phone_number": true, "grade": true, "role": true, "companyCode": true, "company_code": true, "companyCodes": true, "tenant_id": true, "department": true, "position": true, "jobTitle": true, "affiliationType": true, "relying_party_id": true, "custom_login_ids": true, "id": true, } for key, value := range traits { if !coreTraits[key] { user.Metadata[key] = value } } return user } func kratosProjectionTraitString(traits map[string]any, key string) string { if traits == nil { return "" } value, ok := traits[key] if !ok || value == nil { return "" } if str, ok := value.(string); ok { return str } return fmt.Sprint(value) } func kratosProjectionTraitStringArray(traits map[string]any, key string) []string { if traits == nil { return nil } switch value := traits[key].(type) { case []string: return value case []any: items := make([]string, 0, len(value)) for _, item := range value { if str, ok := item.(string); ok && strings.TrimSpace(str) != "" { items = append(items, str) } } return items default: return nil } } func normalizeProjectionStatus(state string) string { normalized := domain.NormalizeUserStatus(state) if normalized == "" { return domain.UserStatusActive } return normalized }