17 changed files with 967 additions and 11 deletions
@ -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'); |
||||||
|
} |
||||||
|
} |
||||||
@ -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; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
@ -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() |
||||||
|
]); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
@ -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()); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
@ -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))); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
@ -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 []; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
@ -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 []; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
@ -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; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
@ -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; |
||||||
|
} |
||||||
|
|
||||||
@ -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; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
Loading…
Reference in new issue