package service import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/repository" "context" "fmt" "strings" "time" "github.com/lib/pq" ) 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 := kratosProjectionTraitString(traits, "grade") if role == "" { role = kratosProjectionTraitString(traits, "role") } role = domain.NormalizeRole(role) if role == "" { role = domain.RoleUser } companyCode := kratosProjectionTraitString(traits, "companyCode") if companyCode == "" { companyCode = kratosProjectionTraitString(traits, "company_code") } 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), CompanyCode: companyCode, CompanyCodes: pq.StringArray(kratosProjectionTraitStringArray(traits, "companyCodes")), Department: kratosProjectionTraitString(traits, "department"), 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]interface{}, 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]interface{}, key string) []string { if traits == nil { return nil } switch value := traits[key].(type) { case []string: return value case []interface{}: 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 { switch strings.ToLower(strings.TrimSpace(state)) { case "blocked", domain.UserStatusInactive: return domain.UserStatusInactive case domain.UserStatusSuspended: return domain.UserStatusSuspended case domain.UserStatusLeaveOfAbsence: return domain.UserStatusLeaveOfAbsence default: return domain.UserStatusActive } }