feat: index Meilisearch customer_contact + sync contacts + onglet NDD

MeilisearchService :
- Nouvel index customer_contact (searchable: firstName, lastName, fullName,
  email, phone, role / filterable: customerId, isBillingEmail)
- indexContact(), removeContact(), searchContacts()
- serializeContact() avec tous les champs

SyncController :
- Route POST /admin/sync/contacts : sync tous les CustomerContact
  dans Meilisearch (setupIndexes + indexContact par contact)
- totalContacts ajouté dans index() via EntityManager

Template admin/sync/index.html.twig :
- Bloc "Contacts" violet avec compteur et bouton Synchroniser

Template admin/clients/show.html.twig :
- Nouvel onglet "Noms de domaine" : table des Domain liés au client
  (fqdn, registrar, Cloudflare, gestion, facturation, expiration)
- Expiration colorée : rouge si expiré, jaune si < 30j, gris sinon

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-04-04 18:00:12 +02:00
parent bf4a0fcb38
commit 619b068d9d
6 changed files with 172 additions and 3 deletions

View File

@@ -2,6 +2,7 @@
namespace App\Controller\Admin;
use App\Entity\CustomerContact;
use App\Entity\StripeWebhookSecret;
use App\Repository\CustomerRepository;
use App\Repository\PriceAutomaticRepository;
@@ -23,7 +24,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
class SyncController extends AbstractController
{
#[Route('', name: 'index')]
public function index(CustomerRepository $customerRepository, RevendeurRepository $revendeurRepository, PriceAutomaticRepository $priceRepository, StripeWebhookSecretRepository $secretRepository): Response
public function index(CustomerRepository $customerRepository, RevendeurRepository $revendeurRepository, PriceAutomaticRepository $priceRepository, StripeWebhookSecretRepository $secretRepository, EntityManagerInterface $em): Response
{
$prices = $priceRepository->findAll();
$stripeSynced = 0;
@@ -58,6 +59,7 @@ class SyncController extends AbstractController
'stripeNotSynced' => $stripeNotSynced,
'customersSynced' => $customersSynced,
'customersNotSynced' => $customersNotSynced,
'totalContacts' => $em->getRepository(CustomerContact::class)->count([]),
'webhookSecrets' => $webhookSecrets,
]);
}
@@ -79,6 +81,23 @@ class SyncController extends AbstractController
return $this->redirectToRoute('app_admin_sync_index');
}
#[Route('/contacts', name: 'contacts', methods: ['POST'])]
public function syncContacts(EntityManagerInterface $em, MeilisearchService $meilisearch): Response
{
try {
$meilisearch->setupIndexes();
$contacts = $em->getRepository(CustomerContact::class)->findAll();
foreach ($contacts as $contact) {
$meilisearch->indexContact($contact);
}
$this->addFlash('success', \count($contacts).' contact(s) synchronise(s) dans Meilisearch.');
} catch (\Throwable $e) {
$this->addFlash('error', 'Erreur sync contacts : '.$e->getMessage());
}
return $this->redirectToRoute('app_admin_sync_index');
}
#[Route('/revendeurs', name: 'revendeurs', methods: ['POST'])]
public function syncRevendeurs(RevendeurRepository $revendeurRepository, MeilisearchService $meilisearch): Response
{

View File

@@ -3,6 +3,7 @@
namespace App\Service;
use App\Entity\Customer;
use App\Entity\CustomerContact;
use App\Entity\PriceAutomatic;
use App\Entity\Revendeur;
use Meilisearch\Client;
@@ -129,6 +130,42 @@ class MeilisearchService
}
}
public function indexContact(CustomerContact $contact): void
{
try {
$this->client->index('customer_contact')->addDocuments([
$this->serializeContact($contact),
]);
} catch (\Throwable $e) {
$this->logger->error('Meilisearch: Failed to index contact '.$contact->getId().': '.$e->getMessage());
}
}
public function removeContact(int $contactId): void
{
try {
$this->client->index('customer_contact')->deleteDocument($contactId);
} catch (\Throwable $e) {
$this->logger->error('Meilisearch: Failed to remove contact '.$contactId.': '.$e->getMessage());
}
}
/**
* @return list<array<string, mixed>>
*/
public function searchContacts(string $query, int $limit = 20): array
{
try {
$results = $this->client->index('customer_contact')->search($query, ['limit' => $limit]);
return $results->getHits();
} catch (\Throwable $e) {
$this->logger->error('Meilisearch: search contacts error: '.$e->getMessage());
return [];
}
}
public function setupIndexes(): void
{
try {
@@ -166,6 +203,18 @@ class MeilisearchService
$this->client->index('price_auto')->updateFilterableAttributes([
'type', 'period',
]);
try {
$this->client->createIndex('customer_contact', ['primaryKey' => 'id']);
} catch (\Throwable $e) {
$this->logger->warning('Meilisearch: setupIndexes (customer_contact) - '.$e->getMessage());
}
$this->client->index('customer_contact')->updateSearchableAttributes([
'firstName', 'lastName', 'fullName', 'email', 'phone', 'role',
]);
$this->client->index('customer_contact')->updateFilterableAttributes([
'customerId', 'isBillingEmail',
]);
}
/**
@@ -239,4 +288,22 @@ class MeilisearchService
'isActive' => $revendeur->isActive(),
];
}
/**
* @return array<string, mixed>
*/
private function serializeContact(CustomerContact $contact): array
{
return [
'id' => $contact->getId(),
'customerId' => $contact->getCustomer()->getId(),
'firstName' => $contact->getFirstName(),
'lastName' => $contact->getLastName(),
'fullName' => $contact->getFullName(),
'email' => $contact->getEmail(),
'phone' => $contact->getPhone(),
'role' => $contact->getRole(),
'isBillingEmail' => $contact->isBillingEmail(),
];
}
}

View File

@@ -40,6 +40,7 @@
'devis': 'Devis',
'impayes': 'Impayes',
'echeancier': 'Echeancier',
'ndd': 'Noms de domaine',
'esyflex': 'EsyFlex',
'sites': 'Sites Internet',
'services': 'Services'
@@ -254,6 +255,58 @@
<div class="glass p-8 text-center text-gray-400 font-bold">Aucun contact.</div>
{% endif %}
{# Tab: Noms de domaine #}
{% elseif tab == 'ndd' %}
{% if domains|length > 0 %}
<div class="glass overflow-hidden">
<table class="w-full text-sm">
<thead>
<tr class="glass-dark text-white">
<th class="px-4 py-3 text-left font-bold uppercase text-xs tracking-widest">Domaine</th>
<th class="px-4 py-3 text-left font-bold uppercase text-xs tracking-widest">Registrar</th>
<th class="px-4 py-3 text-center font-bold uppercase text-xs tracking-widest">Cloudflare</th>
<th class="px-4 py-3 text-center font-bold uppercase text-xs tracking-widest">Gestion</th>
<th class="px-4 py-3 text-center font-bold uppercase text-xs tracking-widest">Facturation</th>
<th class="px-4 py-3 text-left font-bold uppercase text-xs tracking-widest">Expiration</th>
</tr>
</thead>
<tbody>
{% for domain in domains %}
<tr class="border-b border-white/20 hover:bg-white/50">
<td class="px-4 py-3 font-bold font-mono">{{ domain.fqdn }}</td>
<td class="px-4 py-3 text-xs">{{ domain.registrar ?? '—' }}</td>
<td class="px-4 py-3 text-center">
{% if domain.zoneIdCloudflare %}
<span class="px-2 py-0.5 bg-orange-500/20 text-orange-700 font-bold uppercase text-[10px] rounded">{{ domain.zoneCloudflare ?? 'Lie' }}</span>
{% else %}
<span class="text-gray-300">—</span>
{% endif %}
</td>
<td class="px-4 py-3 text-center">
{% if domain.isGestion %}<span class="text-green-600 font-bold">&#10003;</span>{% else %}<span class="text-gray-300">&#10007;</span>{% endif %}
</td>
<td class="px-4 py-3 text-center">
{% if domain.isBilling %}<span class="text-green-600 font-bold">&#10003;</span>{% else %}<span class="text-gray-300">&#10007;</span>{% endif %}
</td>
<td class="px-4 py-3 text-xs">
{% if domain.expiredAt %}
<span class="{{ domain.isExpired ? 'text-red-600 font-bold' : (domain.isExpiringSoon ? 'text-yellow-600 font-bold' : 'text-gray-500') }}">
{{ domain.expiredAt|date('d/m/Y') }}
</span>
{% else %}
<span class="text-gray-300">—</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<p class="mt-3 text-xs text-gray-400">{{ domains|length }} domaine(s)</p>
{% else %}
<div class="glass p-8 text-center text-gray-400 font-bold">Aucun nom de domaine.</div>
{% endif %}
{# Tabs placeholder #}
{% else %}
<div class="glass p-12 text-center">

View File

@@ -52,6 +52,27 @@
</div>
</div>
{# Sync contacts #}
<div class="glass p-6">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-violet-100 border-2 border-violet-600 flex items-center justify-center">
<svg class="w-6 h-6 text-violet-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>
</div>
<div>
<h2 class="font-bold uppercase text-sm">Contacts</h2>
<p class="text-xs text-gray-500">Index Meilisearch : <strong>customer_contact</strong></p>
<p class="text-xs text-gray-400 mt-0.5">{{ totalContacts }} contact(s) en base</p>
</div>
</div>
<form method="post" action="{{ path('app_admin_sync_contacts') }}">
<button type="submit" class="px-4 py-2 btn-glass text-violet-600 font-bold uppercase text-[10px] tracking-wider">
Synchroniser
</button>
</form>
</div>
</div>
{# Sync revendeurs #}
<div class="glass p-6">
<div class="flex items-center justify-between">

View File

@@ -139,8 +139,12 @@ class AdminControllersTest extends TestCase
$prepo = $this->createStub(PriceAutomaticRepository::class);
$srepo = $this->createStub(StripeWebhookSecretRepository::class);
$srepo->method('findAll')->willReturn([]);
$contactRepo = $this->createStub(\Doctrine\ORM\EntityRepository::class);
$contactRepo->method('count')->willReturn(0);
$em = $this->createStub(\Doctrine\ORM\EntityManagerInterface::class);
$em->method('getRepository')->willReturn($contactRepo);
$controller = $this->createMockController(SyncController::class);
$response = $controller->index($crepo, $rrepo, $prepo, $srepo);
$response = $controller->index($crepo, $rrepo, $prepo, $srepo, $em);
$this->assertInstanceOf(Response::class, $response);
}

View File

@@ -94,7 +94,12 @@ class SyncControllerTest extends TestCase
$controller = new SyncController();
$controller->setContainer($this->createContainer());
$response = $controller->index($customerRepo, $revendeurRepo, $priceRepo, $secretRepo);
$contactRepo = $this->createStub(\Doctrine\ORM\EntityRepository::class);
$contactRepo->method('count')->willReturn(0);
$em = $this->createStub(\Doctrine\ORM\EntityManagerInterface::class);
$em->method('getRepository')->willReturn($contactRepo);
$response = $controller->index($customerRepo, $revendeurRepo, $priceRepo, $secretRepo, $em);
$this->assertSame(200, $response->getStatusCode());
}