Browse Source

Skeleton for user search

imwald
Nuša Pukšič 1 month ago
parent
commit
54fb298fe4
  1. 59
      config/packages/fos_elastica.yaml
  2. 27
      config/services.yaml
  3. 45
      migrations/Version20251210170825.php
  4. 56
      src/Command/SyncUserMetadataCommand.php
  5. 4
      src/Controller/Search/SearchController.php
  6. 110
      src/Entity/User.php
  7. 57
      src/EventListener/UserElasticsearchIndexListener.php
  8. 37
      src/EventListener/UserMetadataSyncListener.php
  9. 27
      src/Provider/UserProvider.php
  10. 96
      src/Repository/UserEntityRepository.php
  11. 15
      src/Security/UserDTOProvider.php
  12. 51
      src/Service/Search/DatabaseUserSearch.php
  13. 138
      src/Service/Search/ElasticsearchUserSearch.php
  14. 21
      src/Service/Search/UserSearchFactory.php
  15. 36
      src/Service/Search/UserSearchInterface.php
  16. 192
      src/Service/UserMetadataSyncService.php
  17. 7
      src/Twig/Components/SearchComponent.php

59
config/packages/fos_elastica.yaml

@ -71,3 +71,62 @@ fos_elastica: @@ -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: ~

27
config/services.yaml

@ -66,6 +66,10 @@ services: @@ -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' }
@ -74,6 +78,11 @@ services: @@ -74,6 +78,11 @@ services:
arguments:
$itemPersister: '@fos_elastica.object_persister.articles'
App\EventListener\UserElasticsearchIndexListener:
arguments:
$userPersister: '@fos_elastica.object_persister.users'
$elasticsearchEnabled: '%elasticsearch_enabled%'
App\Command\NostrEventFromYamlDefinitionCommand:
arguments:
$itemPersister: '@fos_elastica.object_persister.articles'
@ -99,3 +108,21 @@ services: @@ -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']

45
migrations/Version20251210170825.php

@ -0,0 +1,45 @@ @@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20251210170825 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->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');
}
}

56
src/Command/SyncUserMetadataCommand.php

@ -0,0 +1,56 @@ @@ -0,0 +1,56 @@
<?php
namespace App\Command;
use App\Service\UserMetadataSyncService;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:sync-user-metadata',
description: 'Sync user metadata from Redis cache to database fields',
)]
class SyncUserMetadataCommand extends Command
{
public function __construct(
private readonly UserMetadataSyncService $syncService
) {
parent::__construct();
}
protected function configure(): void
{
$this
->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;
}
}

4
src/Controller/SearchController.php → src/Controller/Search/SearchController.php

@ -2,7 +2,7 @@ @@ -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; @@ -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', '');

110
src/Entity/User.php

@ -27,6 +27,30 @@ class User implements UserInterface, EquatableInterface @@ -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 @@ -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

57
src/EventListener/UserElasticsearchIndexListener.php

@ -0,0 +1,57 @@ @@ -0,0 +1,57 @@
<?php
namespace App\EventListener;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\ORM\Event\PostPersistEventArgs;
use Doctrine\ORM\Event\PostUpdateEventArgs;
use Doctrine\ORM\Events;
use FOS\ElasticaBundle\Persister\ObjectPersisterInterface;
use Psr\Log\LoggerInterface;
/**
* Automatically indexes users to Elasticsearch when they are created or updated
*/
#[AsDoctrineListener(event: Events::postPersist, priority: 500, connection: 'default')]
#[AsDoctrineListener(event: Events::postUpdate, priority: 500, connection: 'default')]
class UserElasticsearchIndexListener
{
public function __construct(
private readonly ObjectPersisterInterface $userPersister,
private readonly LoggerInterface $logger,
private readonly bool $elasticsearchEnabled = true
) {
}
public function postPersist(PostPersistEventArgs $args): void
{
$this->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()
]);
}
}
}

37
src/EventListener/UserMetadataSyncListener.php

@ -0,0 +1,37 @@ @@ -0,0 +1,37 @@
<?php
namespace App\EventListener;
use App\Entity\User;
use App\Service\UserMetadataSyncService;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\Security\Http\Event\LoginSuccessEvent;
#[AsEventListener(event: LoginSuccessEvent::class)]
class UserMetadataSyncListener
{
public function __construct(
private readonly UserMetadataSyncService $syncService,
private readonly LoggerInterface $logger
) {
}
public function __invoke(LoginSuccessEvent $event): void
{
$user = $event->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());
}
}
}

27
src/Provider/UserProvider.php

@ -0,0 +1,27 @@ @@ -0,0 +1,27 @@
<?php
namespace App\Provider;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use FOS\ElasticaBundle\Provider\PagerfantaPager;
use FOS\ElasticaBundle\Provider\PagerInterface;
use FOS\ElasticaBundle\Provider\PagerProviderInterface;
use Pagerfanta\Adapter\ArrayAdapter;
use Pagerfanta\Pagerfanta;
class UserProvider implements PagerProviderInterface
{
public function __construct(
private readonly EntityManagerInterface $entityManager
) {
}
public function provide(array $options = []): PagerInterface
{
// Get all users for indexing
$users = $this->entityManager->getRepository(User::class)->findAll();
return new PagerfantaPager(new Pagerfanta(new ArrayAdapter($users)));
}
}

96
src/Repository/UserEntityRepository.php

@ -190,4 +190,100 @@ class UserEntityRepository extends ServiceEntityRepository @@ -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();
}
}

15
src/Security/UserDTOProvider.php

@ -4,6 +4,7 @@ namespace App\Security; @@ -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; @@ -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 @@ -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 @@ -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;
}
}

51
src/Service/Search/DatabaseUserSearch.php

@ -0,0 +1,51 @@ @@ -0,0 +1,51 @@
<?php
namespace App\Service\Search;
use App\Entity\User;
use App\Repository\UserEntityRepository;
use Psr\Log\LoggerInterface;
class DatabaseUserSearch implements UserSearchInterface
{
public function __construct(
private readonly UserEntityRepository $userRepository,
private readonly LoggerInterface $logger
) {
}
public function search(string $query, int $limit = 12, int $offset = 0): array
{
try {
return $this->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 [];
}
}
}

138
src/Service/Search/ElasticsearchUserSearch.php

@ -0,0 +1,138 @@ @@ -0,0 +1,138 @@
<?php
namespace App\Service\Search;
use App\Entity\User;
use Elastica\Query;
use Elastica\Query\BoolQuery;
use Elastica\Query\MultiMatch;
use Elastica\Query\Terms;
use FOS\ElasticaBundle\Finder\FinderInterface;
use Psr\Log\LoggerInterface;
class ElasticsearchUserSearch implements UserSearchInterface
{
public function __construct(
private readonly FinderInterface $finder,
private readonly LoggerInterface $logger,
private readonly bool $enabled = true
) {
}
public function search(string $query, int $limit = 12, int $offset = 0): array
{
if (!$this->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 [];
}
}
}

21
src/Service/Search/UserSearchFactory.php

@ -0,0 +1,21 @@ @@ -0,0 +1,21 @@
<?php
namespace App\Service\Search;
class UserSearchFactory
{
public function __construct(
private readonly ElasticsearchUserSearch $elasticsearchUserSearch,
private readonly DatabaseUserSearch $databaseUserSearch,
private readonly bool $elasticsearchEnabled
) {
}
public function create(): UserSearchInterface
{
return $this->elasticsearchEnabled
? $this->elasticsearchUserSearch
: $this->databaseUserSearch;
}
}

36
src/Service/Search/UserSearchInterface.php

@ -0,0 +1,36 @@ @@ -0,0 +1,36 @@
<?php
namespace App\Service\Search;
use App\Entity\User;
interface UserSearchInterface
{
/**
* Search users by query string (searches across name, displayName, nip05, about)
* @param string $query The search query
* @param int $limit Maximum number of results
* @param int $offset Offset for pagination
* @return User[]
*/
public function search(string $query, int $limit = 12, int $offset = 0): array;
/**
* 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;
/**
* 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[]
*/
public function findByRole(string $role, ?string $query = null, int $limit = 12, int $offset = 0): array;
}

192
src/Service/UserMetadataSyncService.php

@ -0,0 +1,192 @@ @@ -0,0 +1,192 @@
<?php
namespace App\Service;
use App\Entity\User;
use App\Repository\UserEntityRepository;
use App\Util\NostrKeyUtil;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
class UserMetadataSyncService
{
public function __construct(
private readonly RedisCacheService $redisCacheService,
private readonly EntityManagerInterface $entityManager,
private readonly UserEntityRepository $userRepository,
private readonly LoggerInterface $logger
) {
}
/**
* Sync metadata for a single user from Redis to database fields
*/
public function syncUser(User $user): bool
{
try {
$npub = $user->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;
}
}

7
src/Twig/Components/SearchComponent.php

@ -4,7 +4,7 @@ namespace App\Twig\Components; @@ -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; @@ -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 @@ -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,

Loading…
Cancel
Save