From 54fb298fe4e72c3076046623a057b21d32d40a87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nu=C5=A1a=20Puk=C5=A1i=C4=8D?= Date: Wed, 10 Dec 2025 19:03:23 +0100 Subject: [PATCH] Skeleton for user search --- config/packages/fos_elastica.yaml | 59 ++++++ config/services.yaml | 27 +++ migrations/Version20251210170825.php | 45 ++++ src/Command/SyncUserMetadataCommand.php | 56 +++++ .../{ => Search}/SearchController.php | 4 +- src/Entity/User.php | 110 +++++++++- .../UserElasticsearchIndexListener.php | 57 ++++++ .../UserMetadataSyncListener.php | 37 ++++ src/Provider/UserProvider.php | 27 +++ src/Repository/UserEntityRepository.php | 96 +++++++++ src/Security/UserDTOProvider.php | 15 +- src/Service/Search/DatabaseUserSearch.php | 51 +++++ .../Search/ElasticsearchUserSearch.php | 138 +++++++++++++ src/Service/Search/UserSearchFactory.php | 21 ++ src/Service/Search/UserSearchInterface.php | 36 ++++ src/Service/UserMetadataSyncService.php | 192 ++++++++++++++++++ src/Twig/Components/SearchComponent.php | 7 +- 17 files changed, 967 insertions(+), 11 deletions(-) create mode 100644 migrations/Version20251210170825.php create mode 100644 src/Command/SyncUserMetadataCommand.php rename src/Controller/{ => Search}/SearchController.php (87%) create mode 100644 src/EventListener/UserElasticsearchIndexListener.php create mode 100644 src/EventListener/UserMetadataSyncListener.php create mode 100644 src/Provider/UserProvider.php create mode 100644 src/Service/Search/DatabaseUserSearch.php create mode 100644 src/Service/Search/ElasticsearchUserSearch.php create mode 100644 src/Service/Search/UserSearchFactory.php create mode 100644 src/Service/Search/UserSearchInterface.php create mode 100644 src/Service/UserMetadataSyncService.php diff --git a/config/packages/fos_elastica.yaml b/config/packages/fos_elastica.yaml index 3cf7ec8..62dccbc 100644 --- a/config/packages/fos_elastica.yaml +++ b/config/packages/fos_elastica.yaml @@ -71,3 +71,62 @@ fos_elastica: provider: ~ listener: ~ finder: ~ + users: + index_name: '%env(ELASTICSEARCH_USER_INDEX_NAME)%' + settings: + index: + refresh_interval: "5s" + number_of_shards: 1 + number_of_replicas: 0 + queries: + cache: + enabled: true + max_result_window: 10000 + analysis: + analyzer: + custom_analyzer: + type: custom + tokenizer: standard + filter: [ lowercase, snowball, asciifolding ] + properties: + npub: + type: keyword + doc_values: true + displayName: + type: text + analyzer: custom_analyzer + term_vector: with_positions_offsets + copy_to: search_combined + name: + type: text + analyzer: custom_analyzer + term_vector: with_positions_offsets + copy_to: search_combined + nip05: + type: keyword + copy_to: search_combined + about: + type: text + analyzer: custom_analyzer + norms: false + copy_to: search_combined + website: + type: keyword + picture: + type: keyword + banner: + type: keyword + lud16: + type: keyword + roles: + type: keyword + search_combined: + type: text + analyzer: standard + persistence: + driver: orm + model: App\Entity\User + provider: + service: App\Provider\UserProvider + listener: ~ + finder: ~ diff --git a/config/services.yaml b/config/services.yaml index 6c771e3..a945dfa 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -66,6 +66,10 @@ services: tags: - { name: fos_elastica.pager_provider, index: articles, type: article } + App\Provider\UserProvider: + tags: + - { name: fos_elastica.pager_provider, index: users, type: user } + App\EventListener\PopulateListener: tags: - { name: kernel.event_listener, event: 'FOS\\ElasticaBundle\\Event\\PostIndexPopulateEvent', method: 'postIndexPopulate' } @@ -73,6 +77,11 @@ services: App\Command\IndexArticlesCommand: arguments: $itemPersister: '@fos_elastica.object_persister.articles' + + App\EventListener\UserElasticsearchIndexListener: + arguments: + $userPersister: '@fos_elastica.object_persister.users' + $elasticsearchEnabled: '%elasticsearch_enabled%' App\Command\NostrEventFromYamlDefinitionCommand: arguments: @@ -99,3 +108,21 @@ services: App\Service\Search\ArticleSearchInterface: factory: ['@App\Service\Search\ArticleSearchFactory', 'create'] + # User Search services - Elasticsearch implementation + App\Service\Search\ElasticsearchUserSearch: + arguments: + $finder: '@fos_elastica.finder.users' + $enabled: '%elasticsearch_enabled%' + + # User Search services - Database implementation + App\Service\Search\DatabaseUserSearch: ~ + + # User Search service factory + App\Service\Search\UserSearchFactory: + arguments: + $elasticsearchEnabled: '%elasticsearch_enabled%' + + # Main user search service - uses Elasticsearch if enabled, otherwise database + App\Service\Search\UserSearchInterface: + factory: ['@App\Service\Search\UserSearchFactory', 'create'] + diff --git a/migrations/Version20251210170825.php b/migrations/Version20251210170825.php new file mode 100644 index 0000000..8c17dcb --- /dev/null +++ b/migrations/Version20251210170825.php @@ -0,0 +1,45 @@ +addSql('ALTER TABLE app_user ADD display_name VARCHAR(255) DEFAULT NULL'); + $this->addSql('ALTER TABLE app_user ADD name VARCHAR(255) DEFAULT NULL'); + $this->addSql('ALTER TABLE app_user ADD nip05 VARCHAR(255) DEFAULT NULL'); + $this->addSql('ALTER TABLE app_user ADD about TEXT DEFAULT NULL'); + $this->addSql('ALTER TABLE app_user ADD website VARCHAR(500) DEFAULT NULL'); + $this->addSql('ALTER TABLE app_user ADD picture VARCHAR(500) DEFAULT NULL'); + $this->addSql('ALTER TABLE app_user ADD banner VARCHAR(500) DEFAULT NULL'); + $this->addSql('ALTER TABLE app_user ADD lud16 VARCHAR(255) DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE app_user DROP display_name'); + $this->addSql('ALTER TABLE app_user DROP name'); + $this->addSql('ALTER TABLE app_user DROP nip05'); + $this->addSql('ALTER TABLE app_user DROP about'); + $this->addSql('ALTER TABLE app_user DROP website'); + $this->addSql('ALTER TABLE app_user DROP picture'); + $this->addSql('ALTER TABLE app_user DROP banner'); + $this->addSql('ALTER TABLE app_user DROP lud16'); + } +} diff --git a/src/Command/SyncUserMetadataCommand.php b/src/Command/SyncUserMetadataCommand.php new file mode 100644 index 0000000..1b1b93d --- /dev/null +++ b/src/Command/SyncUserMetadataCommand.php @@ -0,0 +1,56 @@ +addOption('batch-size', 'b', InputOption::VALUE_OPTIONAL, 'Number of users to process in each batch', 50) + ->setHelp('This command synchronizes user metadata from Redis cache to database fields for all users.'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $batchSize = (int) $input->getOption('batch-size'); + + $io->title('User Metadata Sync'); + $io->text('Starting synchronization of user metadata from Redis to database...'); + + $stats = $this->syncService->syncAllUsers($batchSize); + + $io->success('Metadata sync completed!'); + $io->table( + ['Metric', 'Count'], + [ + ['Total users', $stats['total']], + ['Successfully synced', $stats['synced']], + ['No metadata found', $stats['no_metadata']], + ['Errors', $stats['errors']] + ] + ); + + return Command::SUCCESS; + } +} + diff --git a/src/Controller/SearchController.php b/src/Controller/Search/SearchController.php similarity index 87% rename from src/Controller/SearchController.php rename to src/Controller/Search/SearchController.php index 92c7764..cfc102e 100644 --- a/src/Controller/SearchController.php +++ b/src/Controller/Search/SearchController.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Controller; +namespace App\Controller\Search; use App\Util\ForumTopics; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -12,7 +12,7 @@ use Symfony\Component\Routing\Attribute\Route; class SearchController extends AbstractController { - #[Route('/search')] + #[Route('/search', name: 'app_search_index')] public function index(Request $request): Response { $query = $request->query->get('q', ''); diff --git a/src/Entity/User.php b/src/Entity/User.php index 4337c10..9de9cf7 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -27,6 +27,30 @@ class User implements UserInterface, EquatableInterface #[ORM\Column(type: Types::JSON, nullable: true)] private array $roles = []; + #[ORM\Column(type: Types::STRING, length: 255, nullable: true)] + private ?string $displayName = null; + + #[ORM\Column(type: Types::STRING, length: 255, nullable: true)] + private ?string $name = null; + + #[ORM\Column(type: Types::STRING, length: 255, nullable: true)] + private ?string $nip05 = null; + + #[ORM\Column(type: Types::TEXT, nullable: true)] + private ?string $about = null; + + #[ORM\Column(type: Types::STRING, length: 500, nullable: true)] + private ?string $website = null; + + #[ORM\Column(type: Types::STRING, length: 500, nullable: true)] + private ?string $picture = null; + + #[ORM\Column(type: Types::STRING, length: 500, nullable: true)] + private ?string $banner = null; + + #[ORM\Column(type: Types::STRING, length: 255, nullable: true)] + private ?string $lud16 = null; + private $metadata = null; private $relays = null; @@ -123,7 +147,91 @@ class User implements UserInterface, EquatableInterface public function getName(): ?string { - return $this->getMetadata()->name ?? $this->getUserIdentifier(); + // Return stored name first, fallback to metadata, then npub + return $this->name ?? $this->getMetadata()->name ?? $this->getUserIdentifier(); + } + + public function getDisplayName(): ?string + { + return $this->displayName; + } + + public function setDisplayName(?string $displayName): self + { + $this->displayName = $displayName; + return $this; + } + + public function setName(?string $name): self + { + $this->name = $name; + return $this; + } + + public function getNip05(): ?string + { + return $this->nip05; + } + + public function setNip05(?string $nip05): self + { + $this->nip05 = $nip05; + return $this; + } + + public function getAbout(): ?string + { + return $this->about; + } + + public function setAbout(?string $about): self + { + $this->about = $about; + return $this; + } + + public function getWebsite(): ?string + { + return $this->website; + } + + public function setWebsite(?string $website): self + { + $this->website = $website; + return $this; + } + + public function getPicture(): ?string + { + return $this->picture; + } + + public function setPicture(?string $picture): self + { + $this->picture = $picture; + return $this; + } + + public function getBanner(): ?string + { + return $this->banner; + } + + public function setBanner(?string $banner): self + { + $this->banner = $banner; + return $this; + } + + public function getLud16(): ?string + { + return $this->lud16; + } + + public function setLud16(?string $lud16): self + { + $this->lud16 = $lud16; + return $this; } public function isEqualTo(UserInterface $user): bool diff --git a/src/EventListener/UserElasticsearchIndexListener.php b/src/EventListener/UserElasticsearchIndexListener.php new file mode 100644 index 0000000..4c2fa96 --- /dev/null +++ b/src/EventListener/UserElasticsearchIndexListener.php @@ -0,0 +1,57 @@ +indexUser($args->getObject()); + } + + public function postUpdate(PostUpdateEventArgs $args): void + { + $this->indexUser($args->getObject()); + } + + private function indexUser(object $entity): void + { + if (!$entity instanceof User) { + return; + } + + if (!$this->elasticsearchEnabled) { + return; + } + + try { + $this->userPersister->insertOne($entity); + $this->logger->info("User indexed to Elasticsearch: {$entity->getNpub()}"); + } catch (\Exception $e) { + $this->logger->error("Failed to index user to Elasticsearch: {$entity->getNpub()}", [ + 'error' => $e->getMessage() + ]); + } + } +} + diff --git a/src/EventListener/UserMetadataSyncListener.php b/src/EventListener/UserMetadataSyncListener.php new file mode 100644 index 0000000..a0ddfad --- /dev/null +++ b/src/EventListener/UserMetadataSyncListener.php @@ -0,0 +1,37 @@ +getUser(); + + if (!$user instanceof User) { + return; + } + + try { + // Sync metadata on login + $this->syncService->syncUser($user); + } catch (\Exception $e) { + // Don't fail the login if metadata sync fails + $this->logger->warning("Failed to sync metadata on login for user {$user->getNpub()}: " . $e->getMessage()); + } + } +} + diff --git a/src/Provider/UserProvider.php b/src/Provider/UserProvider.php new file mode 100644 index 0000000..6b31a99 --- /dev/null +++ b/src/Provider/UserProvider.php @@ -0,0 +1,27 @@ +entityManager->getRepository(User::class)->findAll(); + return new PagerfantaPager(new Pagerfanta(new ArrayAdapter($users))); + } +} + diff --git a/src/Repository/UserEntityRepository.php b/src/Repository/UserEntityRepository.php index 9a5679d..243105d 100644 --- a/src/Repository/UserEntityRepository.php +++ b/src/Repository/UserEntityRepository.php @@ -190,4 +190,100 @@ class UserEntityRepository extends ServiceEntityRepository return $pubkeys; } + + /** + * Search users by query string (searches displayName, name, nip05, about) + * @param string $query Search query + * @param int $limit Maximum number of results + * @param int $offset Offset for pagination + * @return User[] + */ + public function searchByQuery(string $query, int $limit = 12, int $offset = 0): array + { + $searchTerm = '%' . strtolower($query) . '%'; + + return $this->createQueryBuilder('u') + ->where('LOWER(u.displayName) LIKE :query') + ->orWhere('LOWER(u.name) LIKE :query') + ->orWhere('LOWER(u.nip05) LIKE :query') + ->orWhere('LOWER(u.about) LIKE :query') + ->orWhere('LOWER(u.npub) LIKE :query') + ->setParameter('query', $searchTerm) + ->setMaxResults($limit) + ->setFirstResult($offset) + ->getQuery() + ->getResult(); + } + + /** + * Find users by npubs + * @param array $npubs Array of npub identifiers + * @param int $limit Maximum number of results + * @return User[] + */ + public function findByNpubs(array $npubs, int $limit = 200): array + { + if (empty($npubs)) { + return []; + } + + return $this->createQueryBuilder('u') + ->where('u.npub IN (:npubs)') + ->setParameter('npubs', $npubs) + ->setMaxResults($limit) + ->getQuery() + ->getResult(); + } + + /** + * Find users by role with optional search query + * @param string $role Role to filter by + * @param string|null $query Optional search query + * @param int $limit Maximum number of results + * @param int $offset Offset for pagination + * @return User[] + * @throws Exception + */ + public function findByRoleWithQuery(string $role, ?string $query = null, int $limit = 12, int $offset = 0): array + { + $conn = $this->entityManager->getConnection(); + + if ($query === null || trim($query) === '') { + // Just filter by role + $sql = 'SELECT id FROM app_user WHERE roles::text LIKE :role LIMIT :limit OFFSET :offset'; + $result = $conn->executeQuery($sql, [ + 'role' => '%' . $role . '%', + 'limit' => $limit, + 'offset' => $offset + ]); + } else { + // Filter by role and search query + $searchTerm = '%' . strtolower($query) . '%'; + $sql = 'SELECT id FROM app_user + WHERE roles::text LIKE :role + AND (LOWER(display_name) LIKE :query + OR LOWER(name) LIKE :query + OR LOWER(nip05) LIKE :query + OR LOWER(about) LIKE :query + OR LOWER(npub) LIKE :query) + LIMIT :limit OFFSET :offset'; + $result = $conn->executeQuery($sql, [ + 'role' => '%' . $role . '%', + 'query' => $searchTerm, + 'limit' => $limit, + 'offset' => $offset + ]); + } + + $ids = $result->fetchFirstColumn(); + if (empty($ids)) { + return []; + } + + return $this->createQueryBuilder('u') + ->where('u.id IN (:ids)') + ->setParameter('ids', $ids) + ->getQuery() + ->getResult(); + } } diff --git a/src/Security/UserDTOProvider.php b/src/Security/UserDTOProvider.php index ff50de8..5d78fbc 100644 --- a/src/Security/UserDTOProvider.php +++ b/src/Security/UserDTOProvider.php @@ -4,6 +4,7 @@ namespace App\Security; use App\Entity\User; use App\Service\RedisCacheService; +use App\Service\UserMetadataSyncService; use App\Util\NostrKeyUtil; use Doctrine\ORM\EntityManagerInterface; use Psr\Log\LoggerInterface; @@ -19,9 +20,10 @@ use Symfony\Component\Security\Core\User\UserProviderInterface; readonly class UserDTOProvider implements UserProviderInterface { public function __construct( - private EntityManagerInterface $entityManager, - private RedisCacheService $redisCacheService, - private LoggerInterface $logger + private EntityManagerInterface $entityManager, + private RedisCacheService $redisCacheService, + private UserMetadataSyncService $metadataSyncService, + private LoggerInterface $logger ) { } @@ -49,6 +51,10 @@ readonly class UserDTOProvider implements UserProviderInterface } $metadata = $this->redisCacheService->getMetadata($pubkey); $freshUser->setMetadata($metadata); + + // Sync metadata to database fields (will also trigger Elasticsearch indexing via listener) + $this->metadataSyncService->syncUser($freshUser); + return $freshUser; } @@ -92,6 +98,9 @@ readonly class UserDTOProvider implements UserProviderInterface $user->setMetadata($metadata); $this->logger->debug('User metadata set.', ['metadata' => json_encode($user->getMetadata())]); + // Sync metadata to database fields (will also trigger Elasticsearch indexing via listener) + $this->metadataSyncService->syncUser($user); + return $user; } } diff --git a/src/Service/Search/DatabaseUserSearch.php b/src/Service/Search/DatabaseUserSearch.php new file mode 100644 index 0000000..fc0854c --- /dev/null +++ b/src/Service/Search/DatabaseUserSearch.php @@ -0,0 +1,51 @@ +userRepository->searchByQuery($query, $limit, $offset); + } catch (\Exception $e) { + $this->logger->error('Database user search error: ' . $e->getMessage()); + return []; + } + } + + public function findByNpubs(array $npubs, int $limit = 200): array + { + if (empty($npubs)) { + return []; + } + + try { + return $this->userRepository->findByNpubs($npubs, $limit); + } catch (\Exception $e) { + $this->logger->error('Database findByNpubs error: ' . $e->getMessage()); + return []; + } + } + + public function findByRole(string $role, ?string $query = null, int $limit = 12, int $offset = 0): array + { + try { + return $this->userRepository->findByRoleWithQuery($role, $query, $limit, $offset); + } catch (\Exception $e) { + $this->logger->error('Database findByRole error: ' . $e->getMessage()); + return []; + } + } +} + diff --git a/src/Service/Search/ElasticsearchUserSearch.php b/src/Service/Search/ElasticsearchUserSearch.php new file mode 100644 index 0000000..281d944 --- /dev/null +++ b/src/Service/Search/ElasticsearchUserSearch.php @@ -0,0 +1,138 @@ +enabled) { + return []; + } + + try { + $mainQuery = new Query(); + $boolQuery = new BoolQuery(); + + // Add phrase match for exact matches (high boost) + $phraseMatch = new Query\MatchPhrase(); + $phraseMatch->setField('search_combined', [ + 'query' => $query, + 'boost' => 10 + ]); + $boolQuery->addShould($phraseMatch); + + // Main multi-match query with optimized settings + $multiMatch = new MultiMatch(); + $multiMatch->setQuery($query); + $multiMatch->setFields([ + 'displayName^3', + 'name^3', + 'nip05^2', + 'about', + 'search_combined' + ]); + $multiMatch->setFuzziness('AUTO'); + $boolQuery->addMust($multiMatch); + + $mainQuery->setQuery($boolQuery); + + // Lower minimum score for better recall + $mainQuery->setMinScore(0.25); + + // Sort by score first + $mainQuery->setSort([ + '_score' => ['order' => 'desc'] + ]); + + $mainQuery->setFrom($offset); + $mainQuery->setSize($limit); + + // Execute the search + $results = $this->finder->find($mainQuery); + $this->logger->info('Elasticsearch user search results count: ' . count($results)); + + return $results; + } catch (\Exception $e) { + $this->logger->error('Elasticsearch user search error: ' . $e->getMessage()); + return []; + } + } + + public function findByNpubs(array $npubs, int $limit = 200): array + { + if (!$this->enabled || empty($npubs)) { + return []; + } + + try { + $termsQuery = new Terms('npub', array_values($npubs)); + $query = new Query($termsQuery); + $query->setSize($limit); + + return $this->finder->find($query); + } catch (\Exception $e) { + $this->logger->error('Elasticsearch findByNpubs error: ' . $e->getMessage()); + return []; + } + } + + public function findByRole(string $role, ?string $query = null, int $limit = 12, int $offset = 0): array + { + if (!$this->enabled) { + return []; + } + + try { + $boolQuery = new BoolQuery(); + + // Add role filter + $termQuery = new Query\Term(); + $termQuery->setTerm('roles', $role); + $boolQuery->addMust($termQuery); + + // Add optional search query + if ($query !== null && trim($query) !== '') { + $multiMatch = new MultiMatch(); + $multiMatch->setQuery($query); + $multiMatch->setFields([ + 'displayName^3', + 'name^3', + 'nip05^2', + 'about', + 'search_combined' + ]); + $multiMatch->setFuzziness('AUTO'); + $boolQuery->addMust($multiMatch); + } + + $mainQuery = new Query($boolQuery); + $mainQuery->setSort([ + '_score' => ['order' => 'desc'] + ]); + $mainQuery->setFrom($offset); + $mainQuery->setSize($limit); + + return $this->finder->find($mainQuery); + } catch (\Exception $e) { + $this->logger->error('Elasticsearch findByRole error: ' . $e->getMessage()); + return []; + } + } +} + diff --git a/src/Service/Search/UserSearchFactory.php b/src/Service/Search/UserSearchFactory.php new file mode 100644 index 0000000..daca361 --- /dev/null +++ b/src/Service/Search/UserSearchFactory.php @@ -0,0 +1,21 @@ +elasticsearchEnabled + ? $this->elasticsearchUserSearch + : $this->databaseUserSearch; + } +} + diff --git a/src/Service/Search/UserSearchInterface.php b/src/Service/Search/UserSearchInterface.php new file mode 100644 index 0000000..6b3df94 --- /dev/null +++ b/src/Service/Search/UserSearchInterface.php @@ -0,0 +1,36 @@ +getNpub(); + + // Convert npub to hex pubkey + if (!NostrKeyUtil::isNpub($npub)) { + $this->logger->warning("Invalid npub format for user: {$npub}"); + return false; + } + + $hexPubkey = NostrKeyUtil::npubToHex($npub); + $metadata = $this->redisCacheService->getMetadata($hexPubkey); + + if ($metadata === null) { + return false; + } + + $this->updateUserFromMetadata($user, $metadata); + $this->entityManager->flush(); + + $this->logger->info("Synced metadata for user: {$npub}"); + return true; + } catch (\Exception $e) { + $this->logger->error("Error syncing metadata for user {$user->getNpub()}: " . $e->getMessage()); + return false; + } + } + + /** + * Sync metadata for all users in the database + * @param int $batchSize Number of users to process in each batch + * @return array Statistics about the sync operation + */ + public function syncAllUsers(int $batchSize = 50): array + { + $stats = [ + 'total' => 0, + 'synced' => 0, + 'no_metadata' => 0, + 'errors' => 0 + ]; + + try { + $users = $this->userRepository->findAll(); + $stats['total'] = count($users); + + $this->logger->info("Starting metadata sync for {$stats['total']} users"); + + $count = 0; + foreach ($users as $user) { + try { + $npub = $user->getNpub(); + + // Convert npub to hex pubkey + if (!NostrKeyUtil::isNpub($npub)) { + $stats['errors']++; + $this->logger->warning("Invalid npub format for user: {$npub}"); + continue; + } + + $hexPubkey = NostrKeyUtil::npubToHex($npub); + $metadata = $this->redisCacheService->getMetadata($hexPubkey); + + if ($metadata === null) { + $stats['no_metadata']++; + continue; + } + + $this->updateUserFromMetadata($user, $metadata); + $stats['synced']++; + $count++; + + // Flush in batches + if ($count % $batchSize === 0) { + $this->entityManager->flush(); + $this->entityManager->clear(); + $this->logger->info("Synced {$count}/{$stats['total']} users"); + } + } catch (\Exception $e) { + $stats['errors']++; + $this->logger->error("Error syncing user {$user->getNpub()}: " . $e->getMessage()); + } + } + + // Flush remaining + $this->entityManager->flush(); + $this->logger->info("Completed metadata sync. Synced: {$stats['synced']}, No metadata: {$stats['no_metadata']}, Errors: {$stats['errors']}"); + } catch (\Exception $e) { + $this->logger->error("Error in syncAllUsers: " . $e->getMessage()); + } + + return $stats; + } + + /** + * Update user entity fields from metadata object + */ + private function updateUserFromMetadata(User $user, object $metadata): void + { + if (isset($metadata->display_name)) { + $user->setDisplayName($this->sanitizeStringValue($metadata->display_name)); + } + if (isset($metadata->name)) { + $user->setName($this->sanitizeStringValue($metadata->name)); + } + if (isset($metadata->nip05)) { + $user->setNip05($this->sanitizeStringValue($metadata->nip05)); + } + if (isset($metadata->about)) { + $user->setAbout($this->sanitizeStringValue($metadata->about)); + } + if (isset($metadata->website)) { + $user->setWebsite($this->sanitizeStringValue($metadata->website)); + } + if (isset($metadata->picture)) { + $user->setPicture($this->sanitizeStringValue($metadata->picture)); + } + if (isset($metadata->banner)) { + $user->setBanner($this->sanitizeStringValue($metadata->banner)); + } + if (isset($metadata->lud16)) { + $user->setLud16($this->sanitizeStringValue($metadata->lud16)); + } + } + + /** + * Sanitize metadata value to ensure it's a string or null + * Handles cases where metadata might be an array or other type + */ + private function sanitizeStringValue(mixed $value): ?string + { + // If already null, return null + if ($value === null) { + return null; + } + + // If it's an array, implode to keep all values + if (is_array($value)) { + // If array is empty, return null + if (empty($value)) { + return null; + } + + // Filter out non-scalar values and convert to strings + $stringValues = array_filter($value, fn($item) => is_scalar($item)); + $stringValues = array_map(fn($item) => (string) $item, $stringValues); + + // If no valid values after filtering, return null + if (empty($stringValues)) { + $this->logger->warning("Metadata field contains array with no scalar values: " . json_encode($value)); + return null; + } + + // Implode with comma separator + return implode(', ', $stringValues); + } + + // If it's an object, return null (can't use object as string) + if (is_object($value)) { + $this->logger->warning("Metadata field contains object: " . get_class($value)); + return null; + } + + // Convert to string + return (string) $value; + } +} + diff --git a/src/Twig/Components/SearchComponent.php b/src/Twig/Components/SearchComponent.php index 2871ec3..4e9710d 100644 --- a/src/Twig/Components/SearchComponent.php +++ b/src/Twig/Components/SearchComponent.php @@ -4,7 +4,7 @@ namespace App\Twig\Components; use App\Credits\Service\CreditsManager; use App\Service\RedisCacheService; -use FOS\ElasticaBundle\Finder\FinderInterface; +use App\Service\Search\ArticleSearchInterface; use Psr\Cache\InvalidArgumentException; use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\RequestStack; @@ -16,9 +16,6 @@ use Symfony\UX\LiveComponent\Attribute\LiveProp; use Symfony\UX\LiveComponent\DefaultActionTrait; use Symfony\UX\LiveComponent\ComponentToolsTrait; use Symfony\Contracts\Cache\CacheInterface; -use Elastica\Query; -use Elastica\Query\BoolQuery; -use Elastica\Query\MultiMatch; #[AsLiveComponent] final class SearchComponent @@ -54,7 +51,7 @@ final class SearchComponent private const SESSION_QUERY_KEY = 'last_search_query'; public function __construct( - private readonly FinderInterface $finder, + private readonly ArticleSearchInterface $articleSearch, private readonly CreditsManager $creditsManager, private readonly TokenStorageInterface $tokenStorage, private readonly LoggerInterface $logger,