<?phpnamespace App\Controller\Api\Cvs;use App\Entity\Cvs\Enterprises;use App\Entity\Cvs\JobPublication;use App\Entity\Cvs\Jobs;use App\Services\RechercheReferentiel;use App\Services\Cvs\JobExpiryService;use Doctrine\ORM\EntityManagerInterface;use Doctrine\ORM\QueryBuilder;use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;use Symfony\Component\HttpFoundation\JsonResponse;use Symfony\Component\HttpFoundation\Request;class PublicJobsController extends AbstractController{ /** * La borne haute. Un endpoint public sans limite est une base de données * offerte à qui la demande. */ private const LIMIT_MAX = 50; /** Soixante secondes. Une offre publiée n'a pas besoin d'être visible dans * la seconde ; une page d'accueil, si. */ private const CACHE_SECONDS = 60; private $entityManager; private $expiry; /** La traduction « ce qu'on tape » → « identifiants du référentiel ». */ private $ref; /** Le tampon des états de publication, chargé en UNE requête par liste. */ private $etats = []; public function __construct( EntityManagerInterface $entityManager, JobExpiryService $expiry, RechercheReferentiel $ref ) { $this->entityManager = $entityManager; $this->expiry = $expiry; $this->ref = $ref; } // ========================================================================= // HELPERS // ========================================================================= /** * Borne la pagination. * * `$start` négatif ferait planter Doctrine. `$limit` non borné offrirait * toute la base en une requête. */ private function clamp(int $start, int $limit): array { $start = max(0, $start); $limit = min(max(1, $limit), self::LIMIT_MAX); return [$start, $limit]; } /** * @param int|null $total Le nombre total d'éléments AVANT pagination. * * ⚠️ IL PART EN EN-TÊTE, PAS DANS LE CORPS. * * Ces endpoints rendent un TABLEAU. Y glisser un `{ items, total }` * changerait la forme de la réponse pour tout le monde — dont * l'application mobile, qui itère directement dessus. Elle * cesserait d'afficher la moindre offre, et rien dans les logs de * l'API ne le dirait. * * `X-Total-Count` est la convention habituelle : les clients qui ne * la connaissent pas l'ignorent, ceux qui en ont besoin la lisent. * C'est ce qui permet à la vitrine de numéroter ses pages et de * proposer « dernière » sans changer un octet du corps. */ private function cacheable(array $data, ?int $total = null): JsonResponse { $response = new JsonResponse($data); $response->setPublic(); $response->setMaxAge(self::CACHE_SECONDS); $response->headers->addCacheControlDirective('must-revalidate'); if ($total !== null) { $response->headers->set('X-Total-Count', (string) $total); /* Sans cette ligne, un client NAVIGATEUR ne verrait pas l'en-tête : le CORS masque tout ce qui n'est pas déclaré exposé, y compris quand la requête aboutit. La vitrine appelle depuis le serveur et n'est pas concernée — le jour où un front l'appellera, si. */ $response->headers->set('Access-Control-Expose-Headers', 'X-Total-Count'); } return $response; } /** * Le nombre de lignes que rendrait cette requête sans pagination. * * ⚠️ ON CLONE — LE QUERYBUILDER EST RÉUTILISÉ ENSUITE. * * Poser le `SELECT COUNT(...)` sur l'original le laisserait dans cet * état : la requête qui suit renverrait un nombre au lieu des * offres. Le clone garde les jointures et les filtres, et n'affecte * rien. * * ⚠️ ET ON NE LAISSE PAS UNE ERREUR TUER LA LISTE. * * Un `COUNT` sur une requête à jointure peut échouer là où le * `SELECT` passe. Mieux vaut une pagination sans « dernière page » * qu'une page d'erreur : on rend `null`, la vitrine retombe sur ses * boutons précédent/suivant. */ private function compter(QueryBuilder $qb, string $alias): ?int { try { /* ⚠️ `COUNT(id)`, PAS `COUNT(DISTINCT id)`. `DISTINCT` force MySQL à matérialiser une table temporaire et à la trier avant de compter — sur 80 000 lignes, c'est le poste le plus cher de la requête. Et il ne sert à rien ici : la jointure est un `ManyToOne` (une offre appartient à UNE entreprise), donc elle ne peut pas multiplier les lignes. `DISTINCT` protégeait contre un risque qui n'existe pas. ⚠️ Si une jointure `OneToMany` était ajoutée un jour à `queryJobs`, il faudrait le remettre — une offre apparaîtrait alors plusieurs fois. */ $compte = (clone $qb) ->select('COUNT(' . $alias . '.id)') ->resetDQLPart('orderBy') ->setFirstResult(null) ->setMaxResults(null) ->getQuery()->getSingleScalarResult(); return (int) $compte; } catch (\Throwable $e) { return null; } } /** * Les filtres du corps de requête. * * Même contrat que `JobsController::extractFilters()` — le site envoie déjà * cette forme. */ private function extractFilters(Request $request): array { $data = json_decode($request->getContent(), true) ?? []; /* Le site envoie `{ searchTerm, filters: {...} }` ; le mobile envoie les filtres à plat. On accepte les deux plutôt que d'imposer une migration à l'un des deux. */ $filters = is_array($data['filters'] ?? null) ? $data['filters'] : $data; $search = $data['searchTerm'] ?? $data['search'] ?? ''; $out = [ 'search' => trim((string) $search), 'employmentType' => $this->toArray($filters['employmentType'] ?? []), 'experienceLevel' => $this->toArray($filters['experienceLevel'] ?? []), 'remoteWork' => $this->toArray($filters['remoteWork'] ?? []), 'city' => trim((string) ($filters['city'] ?? '')), 'category' => trim((string) ($filters['category'] ?? '')), /* ══════════════════════════════════════════════════════════ ⚠️ CE CONTRÔLEUR A SA PROPRE COPIE DES FILTRES. J'avais corrigé ceux de `Api\Cvs\JobsController` — celui de l'espace connecté. C'est CELUI-CI que sert le site public : le filtre pays n'y était pas déclaré du tout, donc jamais lu, donc jamais appliqué. Deux copies d'une même logique, et j'ai corrigé la mauvaise. ══════════════════════════════════════════════════════════ */ 'area' => trim((string) ($filters['area'] ?? '')), 'city_ref' => trim((string) ($filters['city_ref'] ?? '')), /* ⚠️ `trade_ref` N'ÉTAIT PAS DÉCLARÉ — DONC JAMAIS LU. Le site l'envoie depuis longtemps : cliquer un métier dans la sidebar pose `trade_ref` à côté de `category`. Absent de cette liste, il était écarté avant même d'atteindre le filtre, et seul le texte libre servait. Exactement le défaut corrigé pour les villes, resté en place pour les métiers. */ 'trade_ref' => trim((string) ($filters['trade_ref'] ?? '')), ]; /* ⚠️ JOURNAL DE DIAGNOSTIC — SUR LES FILTRES GÉOGRAPHIQUES SEULS. « Le filtre pays ne marche pas » recouvre deux situations très différentes : ou bien la vitrine n'envoie rien, ou bien l'API reçoit et n'applique pas. Sans trace, on ne peut pas trancher — et j'ai corrigé le mauvais contrôleur au tour précédent faute de cette information. On ne journalise que si un filtre géo est présent : sur chaque recherche, ce serait illisible. */ if ($out['area'] !== '' || $out['city'] !== '') { error_log(sprintf( '[public/jobs] filtres géo reçus — area=%s city=%s city_ref=%s', $out['area'] ?: '(vide)', $out['city'] ?: '(vide)', $out['city_ref'] ?: '(vide)' )); } return $out; } /** * Une valeur qui doit être un tableau. * * Un client peut envoyer `employmentType: "cdi"` au lieu de `["cdi"]`. Un * `IN (:x)` avec une chaîne fait planter Doctrine. On normalise plutôt que * de compter sur la discipline de l'appelant. */ private function toArray($value): array { if (is_array($value)) { return array_values(array_filter($value, static fn($v) => $v !== '' && $v !== null)); } if (is_string($value) && $value !== '') { return [$value]; } return []; } private function applyJobFilters(QueryBuilder $qb, array $filters): QueryBuilder { /* ══════════════════════════════════════════════════════════════ LA RECHERCHE TEXTE — ET LE RÉFÉRENTIEL ⚠️ CHERCHER « NEW YORK » NE TROUVAIT PAS LES OFFRES NEW-YORKAISES. La clause ne comparait que du texte libre. Or une offre rattachée à New York porte « NY, USA », « NYC » ou « Remote/Hybrid (US-based) » dans sa colonne `city` — c'est exactement pour ça que le référentiel existe. Le rattachement était fait, l'écran de correction affichait 100 %, et la recherche restait vide. Même défaut pour un pays : « France » ne trouvait rien quand `country` était vide alors que `area_code` valait « FR ». Et pour un métier : « Ressources humaines » ne remontait pas les offres stockées « Human Resources ». ⚠️ ON AJOUTE, ON NE REMPLACE PAS. Le texte libre reste comparé : les offres non rattachées doivent continuer de sortir. Ces conditions viennent en `OR` — élargir la recherche, jamais la restreindre. ⚠️ ET SEULEMENT SUR UNE CORRESPONDANCE EXACTE. `resoudreTerme` n'accepte pas l'à-peu-près : « data » ne doit pas être pris pour une ville, sinon la recherche texte se transformerait en filtre géographique et raterait tout le reste. ══════════════════════════════════════════════════════════════ */ if (!empty($filters['search'])) { $terme = (string) $filters['search']; $vu = $this->ref->resoudreTerme($terme); $clauses = [ 'LOWER(j.jobTitle) LIKE :search', 'LOWER(j.category) LIKE :search', 'LOWER(j.city) LIKE :search', 'LOWER(j.shortDescription) LIKE :search', 'LOWER(e.companyName) LIKE :search', ]; $params = ['search' => '%' . strtolower($terme) . '%']; if ($vu['citySlug'] !== null) { $clauses[] = 'j.citySlug = :searchCitySlug'; $params['searchCitySlug'] = $vu['citySlug']; } if ($vu['areaCode'] !== null) { /* Les régions comprises : taper « France » doit remonter une offre rattachée à « FR-IDF ». */ $clauses[] = 'j.areaCode = :searchArea'; $clauses[] = 'j.areaCode LIKE :searchAreaSub'; $params['searchArea'] = $vu['areaCode']; $params['searchAreaSub'] = $vu['areaCode'] . '-%'; } if ($vu['tradeRefs']) { $clauses[] = 'j.tradeRef IN (:searchTradeRefs)'; $params['searchTradeRefs'] = $vu['tradeRefs']; } $qb->andWhere(implode(' OR ', $clauses)); foreach ($params as $k => $v) { $qb->setParameter($k, $v); } } if (!empty($filters['employmentType'])) { $qb->andWhere('j.employmentType IN (:empTypes)') ->setParameter('empTypes', $filters['employmentType']); } if (!empty($filters['experienceLevel'])) { $qb->andWhere('j.experienceLevel IN (:expLevels)') ->setParameter('expLevels', $filters['experienceLevel']); } if (!empty($filters['remoteWork'])) { $qb->andWhere('j.remoteWork IN (:remoteOpts)') ->setParameter('remoteOpts', $filters['remoteWork']); } /* ══════════════════════════════════════════════════════════════ LA VILLE, PAR LE RÉFÉRENTIEL ⚠️ ON NE COMPARE PLUS LE TEXTE LIBRE. `LIKE '%paris%'` remontait « Paris, Texas » et ratait « Paris 15 ». Le slug est exact et porte sa zone. Une offre non rattachée ne sort plus des filtres ville : c'est le prix de la précision, et l'écran « Correction city » du backoffice sert à les rattacher. ══════════════════════════════════════════════════════════════ */ /* ══════════════════════════════════════════════════════════════ ⚠️ SLUG **OU** RÉFÉRENCE, PAS LES DEUX À LA FOIS. Les deux étaient posés en `AND` : une offre devait porter le slug ET la référence pour sortir. Or ce sont deux populations distinctes — celles rattachées avant la migration n'ont que le slug, celles rattachées depuis ont les deux. En `AND`, la moitié disparaissait. En `OR`, une ville désignée par n'importe lequel des deux identifiants remonte ses offres. La référence d'abord : elle survit à un renommage, le slug non. ══════════════════════════════════════════════════════════════ */ $ville = !empty($filters['city']) ? strtolower(trim($filters['city'])) : null; $villeRef = !empty($filters['city_ref']) ? trim($filters['city_ref']) : null; /* ⚠️ UNE RÉFÉRENCE DÉSIGNE UNE LANDING, LE SLUG DÉSIGNE LA VILLE. Le hero envoie la référence de la landing affichée — celle du public candidat, ou celle des entreprises selon la page. L'offre, elle, peut porter n'importe laquelle des seize. On traduit donc la référence en SLUG avant de comparer : c'est le seul identifiant commun aux deux. Sans ça, une offre rattachée côté entreprise ne sortait pas d'une recherche candidat, et inversement. */ if ($villeRef !== null && $ville === null) { try { $slugDeRef = $this->entityManager->getConnection()->fetchOne( 'SELECT slug FROM bo_geo_city_refs WHERE ref = :r LIMIT 1', ['r' => $villeRef] ); if ($slugDeRef) { $ville = strtolower((string) $slugDeRef); } } catch (\Throwable $e) { /* Table absente : on compare la référence telle quelle. */ } } if ($ville !== null || $villeRef !== null) { $ou = []; $params = []; if ($villeRef !== null) { $ou[] = 'j.cityRef = :filterCityRef'; $params['filterCityRef'] = $villeRef; } if ($ville !== null) { $ou[] = 'j.citySlug = :filterCity'; $params['filterCity'] = $ville; } /* ══════════════════════════════════════════════════════════ ⚠️ LES OFFRES NON RATTACHÉES NE DISPARAISSENT PLUS. Le filtre ne comparait que le rattachement : une offre dont la ville n'est que du texte — le cas de la plupart des offres importées — ne sortait d'AUCUN filtre par ville. Sur une page « Offres à Bruxelles », on lisait « aucun résultat » alors que la base en contenait, simplement pas rattachées. On les rattrape par leur nom, en ÉGALITÉ STRICTE et non en `LIKE` : c'est ce qui évite le retour du défaut d'origine — « Paris » attrapant « Paris, Texas ». « Paris 15 » reste dehors, et c'est le rattachement qui règle ce cas-là. La condition ne porte que sur les offres SANS rattachement : une offre rattachée ailleurs ne doit pas remonter ici parce que son texte libre dit autre chose que sa fiche. ══════════════════════════════════════════════════════════ */ $nomVille = $this->ref->nomDeLaVille($ville ?? ''); if ($nomVille !== null || $ville !== null) { $ou[] = "(j.citySlug IS NULL OR j.citySlug = '') " /* ⚠️ PAS DE `LOWER()` SUR LA COLONNE. Enveloppée dans une fonction, la colonne n'est plus comparable à l'index : MySQL ne peut plus descendre dans l'arbre et relit les 80 000 lignes. Et c'est inutile ici : la table est en `utf8mb4_unicode_ci`, dont la comparaison est DÉJÀ insensible à la casse. `LOWER()` ne changeait donc rien au résultat — seulement au plan d'exécution. */ . 'AND (j.city = :filterCityName OR j.city = :filterCityRaw)'; $params['filterCityName'] = mb_strtolower((string) ($nomVille ?? $ville)); $params['filterCityRaw'] = (string) $ville; } $qb->andWhere('(' . implode(') OR (', $ou) . ')'); foreach ($params as $k => $v) { $qb->setParameter($k, $v); } } /* ⚠️ LE PAYS, RÉGIONS COMPRISES. Demander « CH » doit remonter les offres rattachées à une région suisse. Sans le second motif, seules celles rattachées au pays entier sortiraient — et une ville placée sous sa région disparaîtrait du filtre de son propre pays. */ if (!empty($filters['area'])) { $area = strtoupper(trim($filters['area'])); $ouZone = ['j.areaCode = :filterArea', 'j.areaCode LIKE :filterAreaSub']; /* ⚠️ UNE OFFRE PEUT AVOIR SA VILLE SANS AVOIR SA ZONE. `area_code` est dérivé de la ville à l'enregistrement — donc présent sur tout ce qui a été rattaché depuis cette règle. Restent les anciennes : une ville française renseignée, une zone vide, et une invisibilité totale dans le filtre pays. On ne renvoie pas les six cents villes du pays dans un `IN` (c'est la solution précédente, abandonnée pour son poids) : seulement les slugs RÉELLEMENT portés par des offres orphelines. En base saine, la liste est vide et la clause n'est pas posée. */ $orphelines = $this->ref->villesOrphelinesDeLaZone($area, 'cvs_jobs'); if ($orphelines) { $ouZone[] = 'j.citySlug IN (:filterAreaCities)'; } $qb->andWhere(implode(' OR ', $ouZone)) ->setParameter('filterArea', $area) ->setParameter('filterAreaSub', $area . '-%'); if ($orphelines) { $qb->setParameter('filterAreaCities', $orphelines); } } /* Les offres expirées sortent des résultats publics : elles restent accessibles par leur URL, mais n'encombrent pas une recherche. */ $qb->andWhere('j.isExpired = false OR j.isExpired IS NULL'); /* ══════════════════════════════════════════════════════════════ LE MÉTIER — RÉFÉRENCE **OU** TEXTE LIBRE ⚠️ MÊME RAISONNEMENT QUE POUR LES VILLES, MÊME `OR`. Deux populations cohabitent : les offres rattachées au référentiel (`trade_ref`) et celles dont seule la catégorie en texte libre est renseignée. En `AND`, chaque filtre en perdrait une moitié ; en `OR`, un métier désigné par l'un ou l'autre identifiant remonte ses offres. ⚠️ UNE RÉFÉRENCE DÉSIGNE UNE PUCE, PAS LE MÉTIER. Un métier existe en huit langues côté vitrine, donc jusqu'à huit références pour la même chose. On traduit la référence reçue en SLUG, puis on accepte toutes les références qui partagent ce slug — sans quoi une offre rattachée depuis la page anglaise ne sortirait pas d'une recherche française. ══════════════════════════════════════════════════════════════ */ $refsMetier = $this->ref->referencesMetierFreres( !empty($filters['trade_ref']) ? trim($filters['trade_ref']) : null ); if (!empty($filters['category']) && $refsMetier) { $qb->andWhere('j.tradeRef IN (:filterTradeRefs) OR LOWER(j.category) LIKE :filterCat') ->setParameter('filterTradeRefs', $refsMetier) ->setParameter('filterCat', '%' . strtolower($filters['category']) . '%'); } elseif ($refsMetier) { $qb->andWhere('j.tradeRef IN (:filterTradeRefs)') ->setParameter('filterTradeRefs', $refsMetier); } elseif (!empty($filters['category'])) { $qb->andWhere('LOWER(j.category) LIKE :filterCat') ->setParameter('filterCat', '%' . strtolower($filters['category']) . '%'); } return $qb; } private function applyEnterpriseFilters(QueryBuilder $qb, array $filters): QueryBuilder { /* Même correction que les offres : chercher « New York » doit remonter les fiches rattachées à new-york, quel que soit le texte libre de leur colonne `city`. Deux annuaires qui cherchent différemment donneraient des résultats incohérents pour la même intention. */ if (!empty($filters['search'])) { $terme = (string) $filters['search']; $vu = $this->ref->resoudreTerme($terme); $clauses = [ 'LOWER(e.companyName) LIKE :search', 'LOWER(e.category) LIKE :search', 'LOWER(e.city) LIKE :search', 'LOWER(e.shortDescription) LIKE :search', ]; $params = ['search' => '%' . strtolower($terme) . '%']; if ($vu['citySlug'] !== null) { $clauses[] = 'e.citySlug = :searchCitySlugE'; $params['searchCitySlugE'] = $vu['citySlug']; } if ($vu['areaCode'] !== null) { $clauses[] = 'e.areaCode = :searchAreaE'; $clauses[] = 'e.areaCode LIKE :searchAreaSubE'; $params['searchAreaE'] = $vu['areaCode']; $params['searchAreaSubE'] = $vu['areaCode'] . '-%'; } if ($vu['tradeRefs']) { $clauses[] = 'e.tradeRef IN (:searchTradeRefsE)'; $params['searchTradeRefsE'] = $vu['tradeRefs']; } $qb->andWhere(implode(' OR ', $clauses)); foreach ($params as $k => $v) { $qb->setParameter($k, $v); } } /* ⚠️ MÊME RÈGLE QUE POUR LES OFFRES. L'annuaire filtrait les entreprises sur le texte libre, avec les mêmes défauts : « Paris » attrapait « Paris, Texas ». Laisser les deux écrans diverger donnerait des résultats différents pour la même intention — et l'on ne saurait pas lequel croire. */ /* ══════════════════════════════════════════════════════════════ ⚠️ SLUG **OU** RÉFÉRENCE, COMME POUR LES OFFRES. Les entreprises ne filtraient que par slug : une fiche rattachée par référence seule ne sortait pas. Deux règles différentes entre les deux annuaires donneraient des résultats incohérents pour la même ville — et l'on ne saurait pas lequel croire. ══════════════════════════════════════════════════════════════ */ $villeE = !empty($filters['city']) ? strtolower(trim($filters['city'])) : null; $villeRefE = !empty($filters['city_ref']) ? trim($filters['city_ref']) : null; if ($villeE !== null || $villeRefE !== null) { $ouE = []; $paramsE = []; if ($villeRefE !== null) { $ouE[] = 'e.cityRef = :filterCityRefE'; $paramsE['filterCityRefE'] = $villeRefE; } if ($villeE !== null) { $ouE[] = 'e.citySlug = :filterCityE'; $paramsE['filterCityE'] = $villeE; } /* Même repli que pour les offres : une fiche non rattachée reste trouvable par son nom de ville, en égalité stricte. */ $nomVilleE = $this->ref->nomDeLaVille($villeE ?? ''); if ($nomVilleE !== null || $villeE !== null) { $ouE[] = "(e.citySlug IS NULL OR e.citySlug = '') " /* Même raison que pour les offres : la collation rend déjà la comparaison insensible à la casse, et `LOWER()` interdisait l'index. */ . 'AND (e.city = :filterCityNameE OR e.city = :filterCityRawE)'; $paramsE['filterCityNameE'] = mb_strtolower((string) ($nomVilleE ?? $villeE)); $paramsE['filterCityRawE'] = (string) $villeE; } $qb->andWhere('(' . implode(') OR (', $ouE) . ')'); foreach ($paramsE as $k => $v) { $qb->setParameter($k, $v); } } if (!empty($filters['area'])) { $area = strtoupper(trim($filters['area'])); $ouZoneE = ['e.areaCode = :filterArea', 'e.areaCode LIKE :filterAreaSub']; /* Même rattrapage que les offres : ville rattachée, zone vide. */ $orphelinesE = $this->ref->villesOrphelinesDeLaZone($area, 'cvs_enterprises'); if ($orphelinesE) { $ouZoneE[] = 'e.citySlug IN (:filterAreaCitiesE)'; } $qb->andWhere(implode(' OR ', $ouZoneE)) ->setParameter('filterArea', $area) ->setParameter('filterAreaSub', $area . '-%'); if ($orphelinesE) { $qb->setParameter('filterAreaCitiesE', $orphelinesE); } } /* Le secteur : même règle que les offres — référence du référentiel OU texte libre, en `OR`. Deux annuaires qui filtrent différemment donneraient des résultats incohérents pour la même intention. */ $refsSecteur = $this->ref->referencesMetierFreres( !empty($filters['trade_ref']) ? trim($filters['trade_ref']) : null ); if (!empty($filters['category']) && $refsSecteur) { $qb->andWhere('e.tradeRef IN (:filterTradeRefsE) OR LOWER(e.category) LIKE :filterCatE') ->setParameter('filterTradeRefsE', $refsSecteur) ->setParameter('filterCatE', '%' . strtolower($filters['category']) . '%'); } elseif ($refsSecteur) { $qb->andWhere('e.tradeRef IN (:filterTradeRefsE)') ->setParameter('filterTradeRefsE', $refsSecteur); } elseif (!empty($filters['category'])) { $qb->andWhere('LOWER(e.category) LIKE :filterCatE') ->setParameter('filterCatE', '%' . strtolower($filters['category']) . '%'); } return $qb; } // ========================================================================= // REQUÊTES // ========================================================================= /** * Les offres en ligne. * * `(e.id IS NULL OR e.online = true)` : une offre sans entreprise reliée est * légitime (import websearch). Une offre reliée à une entreprise hors ligne * ne doit pas s'afficher — c'est la règle de `JobsController`, on la garde. */ private function queryJobs(): QueryBuilder { $qb = $this->entityManager->getRepository(Jobs::class) ->createQueryBuilder('j') ->leftJoin('j.enterprise', 'e') ->where('j.online = :online') ->andWhere('(e.id IS NULL OR e.online = :online)') ->setParameter('online', true) ->orderBy('j.updatedAt', 'DESC'); return $this->excludeExpired($qb); } /** * ═══════════════════════════════════════════════════════════════════════ * ⚠️ ON N'EXCLUT QUE CE QU'ON SAIT EXPIRÉ. PAS CE QU'ON SUPPOSE. * ═══════════════════════════════════════════════════════════════════════ * * La base contient des dizaines de milliers d'offres publiées AVANT que la * table `cvs_job_publication` n'existe. Aucune n'a de date de publication * enregistrée : on la CALCULE depuis `createdAt`, et beaucoup ont plus de * 90 jours. * * Les exclure toutes viderait le site du jour au lendemain. Ce n'est pas * une décision technique — c'est une décision de produit, et elle * n'appartient pas à ce contrôleur. * * Donc : * * · Offre AVEC une publication enregistrée et expirée → EXCLUE * · Offre AVEC une publication fermée (pourvue/retirée) → EXCLUE * · Offre SANS publication (héritée) → GARDÉE * * L'expiration s'applique donc à partir d'aujourd'hui, pour les offres * dont on connaît la vraie date. Le passé n'est pas réécrit. * * Le README contient la requête pour compter combien d'offres héritées * auraient plus de 90 jours, et le script de rattrapage à lancer LE JOUR * OÙ TU AURAS VU LE CHIFFRE. * * ⚠️ Cette liste reste visible sur la page de l'offre : `expired: true` y * est renvoyé, et le site affiche « cette offre a expiré ». On sort des * listes, on ne fait pas disparaître la page — Google et les liens en * circulation attendent qu'elle réponde. */ private function excludeExpired(QueryBuilder $qb): QueryBuilder { /* Une sous-requête plutôt qu'une jointure : on veut « il n'existe PAS de publication expirée pour cette offre », ce qui inclut « il n'existe aucune publication du tout ». Une jointure LEFT + IS NULL dirait la même chose, mais moins clairement. */ /* ⚠️ `::class`, PAS UNE CHAÎNE. J'AVAIS ÉCRIT : ->from('App\\\\Entity\\\\Cvs\\\\JobPublication', 'pub') En chaîne SIMPLE, chaque `\\\\` donne `\\`. Doctrine cherchait donc une entité nommée `App\\Entity\\Cvs\\JobPublication` — avec des DOUBLES antislashs — et ne trouvait rien. Erreur à la première requête, pas à la compilation : invisible jusqu'en production. `::class` ne peut pas se tromper d'échappement, et il suit les renommages. ⚠️ Et `SELECT 1` en DQL est fragile : selon la version de Doctrine, le parseur l'accepte ou le refuse. `pub.id` est toujours valide. */ $sub = $this->entityManager->createQueryBuilder() ->select('pub.id') ->from(JobPublication::class, 'pub') ->where('pub.job = j') ->andWhere('(pub.expiresAt < :maintenant OR pub.closedReason IS NOT NULL)') ->getDQL(); return $qb ->andWhere('NOT EXISTS (' . $sub . ')') ->setParameter('maintenant', new \DateTime()); } /** * L'état d'une offre, depuis le tampon s'il est chargé. * * Sur un DÉTAIL (une seule offre), le tampon est vide : on fait la requête. * Sur une LISTE, `serializeJobList()` l'a déjà rempli en une fois. */ private function etat(Jobs $job): array { $id = $job->getId(); if (!isset($this->etats[$id])) { $this->etats[$id] = $this->expiry->state($job); } return $this->etats[$id]; } /** * GET /api/public/jobs/sitemap/{start}/{limit} * * ══════════════════════════════════════════════════════════════════ * LES OFFRES POUR LE PLAN DU SITE — identifiant, slug, date. Rien d'autre. * * ⚠️ POURQUOI UN ENDPOINT À PART. * * La liste publique plafonne à 50 offres par appel — la bonne * valeur pour un écran, absurde pour un plan de site : 80 000 * offres demanderaient 1 600 allers-retours HTTP. * * Et elle renvoie la fiche entière : titre, description, salaire, * entreprise. Un plan de site n'a besoin que de l'URL et de la * date. Sur 80 000 lignes, l'écart se compte en centaines de * mégaoctets transférés pour rien. * * Ici : trois colonnes, 5 000 lignes par appel. Seize appels * suffisent au catalogue entier. * * ⚠️ `getArrayResult`, PAS `getResult`. * * Hydrater 5 000 entités Doctrine pour lire trois champs sature * la mémoire de PHP. Le tableau brut ne construit aucun objet. * * ⚠️ ET LE TRI EST SUR L'IDENTIFIANT, PAS SUR LA DATE. * * Une pagination par `OFFSET` sur un tri par date de mise à jour * n'est pas stable : une offre modifiée entre deux appels change * de place, et se retrouve lue deux fois — ou jamais. Sur un plan * de site, c'est une URL manquante qu'on ne remarque pas. * ══════════════════════════════════════════════════════════════════ */ public function sitemapJobs(int $start, int $limit): JsonResponse { /* Cinq mille : au-delà, la réponse JSON dépasse quelques mégaoctets et le gain d'un appel de moins ne compense plus. */ $limit = min(max(1, $limit), 5000); $start = max(0, $start); $lignes = $this->queryJobs() ->select('j.id, j.slug, j.updatedAt') ->resetDQLPart('orderBy') ->orderBy('j.id', 'ASC') ->setFirstResult($start) ->setMaxResults($limit) ->getQuery()->getArrayResult(); $jobs = array_map(static function (array $j): array { return [ 'id' => (int) $j['id'], 'slug' => $j['slug'], 'updatedAt' => $j['updatedAt'] instanceof \DateTimeInterface ? $j['updatedAt']->format('Y-m-d') : null, ]; }, $lignes); /* Le total accompagne chaque page : le client sait combien d'appels il lui reste sans avoir à deviner ni à boucler jusqu'au vide. */ return $this->cacheable($jobs, $this->compter($this->queryJobs(), 'j')); } private function queryEnterprises(): QueryBuilder { return $this->entityManager->getRepository(Enterprises::class) ->createQueryBuilder('e') ->where('e.online = :online') ->setParameter('online', true) ->orderBy('e.updatedAt', 'DESC'); } // ========================================================================= // OFFRES // ========================================================================= public function jobs(int $start, int $limit): JsonResponse { [$start, $limit] = $this->clamp($start, $limit); $qb = $this->queryJobs(); $total = $this->compter($qb, 'j'); $jobs = $qb->setFirstResult($start)->setMaxResults($limit) ->getQuery()->getResult(); return $this->cacheable($this->serializeJobList($jobs), $total); } public function searchJobs(Request $request, int $start, int $limit): JsonResponse { [$start, $limit] = $this->clamp($start, $limit); $qb = $this->applyJobFilters($this->queryJobs(), $this->extractFilters($request)); $total = $this->compter($qb, 'j'); $jobs = $qb->setFirstResult($start)->setMaxResults($limit) ->getQuery()->getResult(); return $this->cacheable($this->serializeJobList($jobs), $total); } public function job(int $id): JsonResponse { $job = $this->entityManager->getRepository(Jobs::class)->find($id); /* ⚠️ UNE OFFRE HORS LIGNE N'EXISTE PAS POUR UN ANONYME. Renvoyer une 403 dirait « elle existe, mais tu n'y as pas droit » — ce qui permet d'énumérer les offres dépubliées. 404 : elle n'existe pas, point. */ if (!$job || !$job->isOnline()) { return new JsonResponse(['error' => 'NOT_FOUND'], 404); } $enterprise = $job->getEnterprise(); if ($enterprise && !$enterprise->isOnline()) { return new JsonResponse(['error' => 'NOT_FOUND'], 404); } return $this->cacheable($this->serializeJobDetails($job)); } /** * Un PAQUET ALÉATOIRE d'offres — pour le mode swipe. * * Le site présente les offres en pile qu'on fait défiler : on veut des * paquets de N offres, variés à chaque appel, sans que le client ait à * paginer. On ne demande pas `RAND()` à MySQL (un tri aléatoire sur toute * la table est coûteux) : on tire un LOT plus large des offres récentes, * on le mélange en PHP, on en garde N. C'est aléatoire « assez » pour * l'usage, et ça reste une seule requête bornée. * * `exclude` (optionnel, query string, ids séparés par des virgules) retire * l'offre déjà affichée et celles déjà vues, pour éviter les répétitions. */ public function randomJobs(Request $request, int $count): JsonResponse { $count = max(1, min($count, self::LIMIT_MAX)); // On pioche dans un vivier plus large que N, puis on mélange. $vivier = min(self::LIMIT_MAX, max($count * 4, 40)); $qb = $this->queryJobs(); /* ══════════════════════════════════════════════════════════════ ⚠️ `?rattachees=1` — SEULEMENT LES OFFRES RELIÉES AU RÉFÉRENTIEL. L'accueil montre ces offres avec leur ville. Une offre non rattachée y affiche « NY, USA » ou « Remote/Hybrid (US-based) », c'est-à-dire la saisie brute du recruteur, à côté d'offres qui annoncent proprement « New York ». Sur la page la plus vue du site, l'incohérence saute aux yeux. Ce filtre ne corrige rien — il choisit de ne montrer que ce qui est présentable. ══════════════════════════════════════════════════════════════ */ if ($request->query->get('rattachees') === '1') { $qb->andWhere("j.cityRef IS NOT NULL AND j.cityRef <> ''"); } /* ══════════════════════════════════════════════════════════════ ⚠️ UN DÉCALAGE ALÉATOIRE, PAS TOUJOURS LES PLUS RÉCENTES. Le vivier était pris en tête de la liste triée par date : on mélangeait donc les quarante dernières offres, encore et encore. Deux visites à dix minutes d'intervalle montraient les mêmes annonces dans un ordre différent — ce qui se voit, et donne l'impression d'un catalogue étroit. On tire maintenant une TRANCHE au hasard dans l'ensemble. Un `ORDER BY RAND()` sur quatre-vingt mille lignes trierait toute la table à chaque appel ; un `COUNT` puis un décalage coûtent deux requêtes bornées, et la variété est réelle. ⚠️ LE DÉCALAGE EST BORNÉ PAR LE TOTAL MOINS LE VIVIER. Sinon une offre en fin de liste sortirait un vivier de trois lignes, et l'accueil afficherait trois offres au lieu de huit sans que rien ne le signale. ══════════════════════════════════════════════════════════════ */ $total = $this->compter($qb, 'j'); if ($total !== null && $total > $vivier) { $qb->setFirstResult(random_int(0, $total - $vivier)); } $jobs = $qb ->setMaxResults($vivier) ->getQuery()->getResult(); // Exclusions (offre courante + déjà vues). $exclude = array_filter(array_map('trim', explode(',', (string) $request->query->get('exclude', '')))); if ($exclude) { $set = array_flip($exclude); $jobs = array_values(array_filter($jobs, static function ($j) use ($set) { return !isset($set[(string) $j->getId()]); })); } shuffle($jobs); $jobs = array_slice($jobs, 0, $count); return $this->cacheable($this->serializeJobList($jobs)); } /** * LES FACETTES — villes et catégories reliées aux offres en ligne. * * L'ancienne sidebar du site vitrine listait les villes et les catégories * de jobs, paginées. On les calcule ici, avec le nombre d'offres par * valeur (utile pour trier et afficher un compteur). Deux requêtes * agrégées, pas de N+1. On ne renvoie que ce qui a au moins une offre. */ public function jobFacets(): JsonResponse { $cities = $this->entityManager->getRepository(Jobs::class) ->createQueryBuilder('j') ->select('j.city AS value, COUNT(j.id) AS total') ->leftJoin('j.enterprise', 'e') ->where('j.online = :on') ->andWhere('(e.id IS NULL OR e.online = :on)') ->andWhere('j.city IS NOT NULL') ->andWhere("j.city <> ''") ->setParameter('on', true) ->groupBy('j.city') ->orderBy('total', 'DESC') ->getQuery()->getArrayResult(); $categories = $this->entityManager->getRepository(Jobs::class) ->createQueryBuilder('j') ->select('j.category AS value, COUNT(j.id) AS total') ->leftJoin('j.enterprise', 'e') ->where('j.online = :on') ->andWhere('(e.id IS NULL OR e.online = :on)') ->andWhere('j.category IS NOT NULL') ->andWhere("j.category <> ''") ->setParameter('on', true) ->groupBy('j.category') ->orderBy('total', 'DESC') ->getQuery()->getArrayResult(); $map = static function (array $rows): array { return array_map(static function ($r) { return ['value' => $r['value'], 'count' => (int) $r['total']]; }, $rows); }; return $this->cacheable([ 'cities' => $map($cities), 'categories' => $map($categories), ]); } /** * Le détail d'une offre PAR SON SLUG. * * Le site public expose les offres sous /job/{slug} : une URL lisible et * stable, meilleure pour le référencement qu'un identifiant numérique. On * retrouve donc l'offre par `slug`. Mêmes règles que `job()` : une offre * hors ligne, ou dont l'entreprise est hors ligne, n'existe pas (404) — on * n'énumère rien. * * Un `slug` peut, en théorie, ne pas être unique (rien ne l'impose en * base). On prend la plus récente en ligne : c'est celle que le site vient * de lister. */ public function jobBySlug(string $slug): JsonResponse { $job = $this->entityManager->getRepository(Jobs::class) ->createQueryBuilder('j') ->where('j.slug = :slug') ->andWhere('j.online = :online') ->setParameter('slug', $slug) ->setParameter('online', true) ->orderBy('j.updatedAt', 'DESC') ->setMaxResults(1) ->getQuery()->getOneOrNullResult(); if (!$job) { /* ══════════════════════════════════════════════════════════════ ⚠️ 410 SI L'OFFRE A EXISTÉ, 404 SI ELLE N'A JAMAIS EXISTÉ. La requête ci-dessus exige `online = true` : une offre dépubliée ou expirée en sort, et rendait 404 — le même code qu'une adresse inventée. Or les deux ne disent pas la même chose à un robot d'indexation. « 404 » signifie « pas là, réessaie plus tard » : il revient, indéfiniment. « 410 » signifie « c'était là, c'est fini » : il retire l'adresse de son index et cesse de la demander. Le rapport d'incident relève 5 523 appels sur des offres mortes, re-demandées en boucle. C'est ce trafic-là que le 410 supprime — pas immédiatement, mais définitivement. Le second `getOneOrNullResult` coûte une requête de plus, et seulement dans le cas où l'on répondait déjà en erreur. ══════════════════════════════════════════════════════════════ */ $aExiste = $this->entityManager->getRepository(Jobs::class) ->createQueryBuilder('j') ->select('j.id') ->where('j.slug = :slug') ->setParameter('slug', $slug) ->setMaxResults(1) ->getQuery()->getOneOrNullResult(); return new JsonResponse( ['error' => $aExiste ? 'GONE' : 'NOT_FOUND'], $aExiste ? 410 : 404, ); } $enterprise = $job->getEnterprise(); if ($enterprise && !$enterprise->isOnline()) { /* L'entreprise s'est retirée : l'offre a bien existé, elle ne reviendra pas sous cette adresse. Même raisonnement. */ return new JsonResponse(['error' => 'GONE'], 410); } return $this->cacheable($this->serializeJobDetails($job)); } public function enterprises(int $start, int $limit): JsonResponse { [$start, $limit] = $this->clamp($start, $limit); $qb = $this->queryEnterprises(); $total = $this->compter($qb, 'e'); $enterprises = $qb->setFirstResult($start)->setMaxResults($limit) ->getQuery()->getResult(); return $this->cacheable($this->serializeEnterpriseList($enterprises), $total); } public function searchEnterprises(Request $request, int $start, int $limit): JsonResponse { [$start, $limit] = $this->clamp($start, $limit); $qb = $this->applyEnterpriseFilters($this->queryEnterprises(), $this->extractFilters($request)); $total = $this->compter($qb, 'e'); $enterprises = $qb->setFirstResult($start)->setMaxResults($limit) ->getQuery()->getResult(); return $this->cacheable($this->serializeEnterpriseList($enterprises), $total); } public function enterprise(int $id): JsonResponse { $enterprise = $this->entityManager->getRepository(Enterprises::class)->find($id); if (!$enterprise || !$enterprise->isOnline()) { return new JsonResponse(['error' => 'NOT_FOUND'], 404); } return $this->cacheable($this->serializeEnterpriseDetails($enterprise)); } /** * Le détail d'une entreprise PAR SON SLUG. * * Le site public expose /company/{slug} : une URL lisible et stable, * meilleure pour le référencement qu'un identifiant. Mêmes règles que * `enterprise()` : hors ligne = 404 (on n'énumère rien). Slug non * garanti unique en base → on prend la plus récente en ligne. */ public function enterpriseBySlug(string $slug): JsonResponse { $enterprise = $this->entityManager->getRepository(Enterprises::class) ->createQueryBuilder('e') ->where('e.slug = :slug') ->andWhere('e.online = :online') ->setParameter('slug', $slug) ->setParameter('online', true) ->orderBy('e.updatedAt', 'DESC') ->setMaxResults(1) ->getQuery()->getOneOrNullResult(); if (!$enterprise) { return new JsonResponse(['error' => 'NOT_FOUND'], 404); } return $this->cacheable($this->serializeEnterpriseDetails($enterprise)); } /** * Un PAQUET ALÉATOIRE d'entreprises — pour le mode swipe de l'annuaire. * * Même principe que `randomJobs` : on pioche dans un vivier plus large que * N, on mélange en PHP, on garde N. `exclude` retire les entreprises déjà * vues (ids séparés par des virgules). */ public function randomEnterprises(Request $request, int $count): JsonResponse { $count = max(1, min($count, self::LIMIT_MAX)); $vivier = min(self::LIMIT_MAX, max($count * 4, 40)); $enterprises = $this->queryEnterprises() ->setMaxResults($vivier) ->getQuery()->getResult(); $exclude = array_filter(array_map('trim', explode(',', (string) $request->query->get('exclude', '')))); if ($exclude) { $set = array_flip($exclude); $enterprises = array_values(array_filter($enterprises, static function ($e) use ($set) { return !isset($set[(string) $e->getId()]); })); } shuffle($enterprises); $enterprises = array_slice($enterprises, 0, $count); return $this->cacheable($this->serializeEnterpriseList($enterprises)); } // ========================================================================= // FLUX MIXTE (page d'accueil) // ========================================================================= public function mixed(int $start, int $limit): JsonResponse { [$start, $limit] = $this->clamp($start, $limit); $half = max(1, (int) ($limit / 2)); $enterprises = $this->queryEnterprises() ->setFirstResult($start)->setMaxResults($half) ->getQuery()->getResult(); $jobs = $this->queryJobs() ->setFirstResult($start)->setMaxResults($half) ->getQuery()->getResult(); return $this->cacheable($this->mergeMixed($enterprises, $jobs)); } public function searchMixed(Request $request, int $start, int $limit): JsonResponse { [$start, $limit] = $this->clamp($start, $limit); $filters = $this->extractFilters($request); $half = max(1, (int) ($limit / 2)); $enterprises = $this->applyEnterpriseFilters($this->queryEnterprises(), $filters) ->setFirstResult($start)->setMaxResults($half) ->getQuery()->getResult(); $jobs = $this->applyJobFilters($this->queryJobs(), $filters) ->setFirstResult($start)->setMaxResults($half) ->getQuery()->getResult(); return $this->cacheable($this->mergeMixed($enterprises, $jobs)); } /** Le flux mixte : chaque objet porte son `type`, comme dans JobsController. */ private function mergeMixed(array $enterprises, array $jobs): array { $result = []; foreach ($this->serializeEnterpriseList($enterprises) as $e) { $e['type'] = 'enterprise'; $result[] = $e; } foreach ($this->serializeJobList($jobs) as $j) { $j['type'] = 'job'; $result[] = $j; } return $result; } private function serializeJobList(array $jobs): array { /* ⚠️ UNE REQUÊTE POUR TOUTES LES DATES, PAS UNE PAR OFFRE. `state()` dans la boucle, ce serait 13 requêtes de plus pour 13 lignes. Ce contrôleur existe justement parce que l'ancien en faisait déjà trop. On ne va pas ajouter un N+1 en corrigeant un N+1. */ $this->etats = $this->expiry->states($jobs); $result = []; foreach ($jobs as $job) { $result[] = $this->serializeJob($job); } return $result; } private function serializeEnterpriseList(array $enterprises): array { $result = []; foreach ($enterprises as $enterprise) { $jobsCount = $this->entityManager->getRepository(Jobs::class) ->createQueryBuilder('j') ->select('COUNT(j.id)') ->where('j.enterprise = :ent') ->andWhere('j.online = :on') ->andWhere('j.verification = :ver') ->setParameter('ent', $enterprise) ->setParameter('on', true) ->setParameter('ver', true) ->getQuery()->getSingleScalarResult(); $data = $this->serializeEnterprise($enterprise); $data['jobOffersCount'] = (int) $jobsCount; $result[] = $data; } return $result; } private function serializeJob($job): array { $projectDir = $this->getParameter('kernel.project_dir'); $enterprise = $job->getEnterprise(); $skills = $job->getRequiredSkills(); if (is_string($skills)) { $d = json_decode($skills, true); $skills = is_array($d) ? $d : []; } $nice = $job->getNiceToHaveSkills(); if (is_string($nice)) { $d = json_decode($nice, true); $nice = is_array($d) ? $d : []; } return [ 'id' => (string) $job->getId(), 'title' => $job->getJobTitle(), 'shortTitle' => $job->getShortTitle(), 'shortDescription' => $job->getShortDescription(), 'category' => $job->getCategory(), 'employmentType' => $job->getEmploymentType(), 'experienceLevel' => $job->getExperienceLevel(), 'remoteWork' => $job->getRemoteWork(), 'city' => $job->getCity(), 'country' => $job->getCountry(), /* ⚠️ LE RATTACHEMENT AU RÉFÉRENTIEL, TRANSMIS TEL QUEL. `cityRef` (VIL-…) est copié depuis le backoffice du site vitrine — c'est LÀ que vivent le nom canonique de la ville et son pays, traduits en huit langues. L'API ne les résout pas : elle n'a pas les libellés par langue, et n'a pas à les avoir. Le site les affiche EN PRIORITÉ et retombe sur `city`/`country` ci-dessus quand l'offre n'est pas rattachée. `cityRaw` garde la saisie du recruteur, qui porte parfois une précision qu'aucun slug ne rend — « Remote/Hybrid (US-based) ». */ 'tradeRef' => $job->getTradeRef(), 'cityRef' => $job->getCityRef(), 'citySlug' => $job->getCitySlug(), 'areaCode' => $job->getAreaCode(), 'cityRaw' => $job->getCity(), 'salaryMin' => $job->getSalaryMin(), 'salaryMax' => $job->getSalaryMax(), 'devise' => $job->getDevise(), 'salaryPeriod' => $job->getSalaryPeriod(), 'skills' => $skills, 'niceToHaveSkills' => $nice, 'createdAt' => $job->getCreatedAt()->format('c'), 'updatedAt' => $job->getUpdatedAt()->format('c'), /* ⚠️ LE CYCLE DE VIE. C'EST CE QUE GOOGLE LIT. `publishedAt` → datePosted `expiresAt` → validThrough ← sans lui, Google déclasse l'offre `estimated: true` signale une offre d'AVANT la table de publication : la date est calculée depuis `createdAt`. Le site doit nuancer son message plutôt qu'affirmer une date qu'on n'a jamais enregistrée. */ 'publishedAt' => $this->etat($job)['publishedAt'], 'expiresAt' => $this->etat($job)['expiresAt'], 'expired' => $this->etat($job)['expired'], 'daysLeft' => $this->etat($job)['daysLeft'], 'closedReason' => $this->etat($job)['closedReason'], 'estimated' => $this->etat($job)['estimated'], // Anonyme : rien n'est liké. Aucune requête. 'liked' => false, 'websearch' => (bool) $job->isWebsearch(), 'website' => $job->getWebsite(), 'contactURL' => $job->getContactURL(), 'slug' => $job->getSlug(), 'enterprise' => $enterprise ? [ 'id' => (string) $enterprise->getId(), 'slug' => $enterprise->getSlug(), 'locale' => $enterprise->getLocale(), 'name' => $enterprise->getCompanyName(), 'logo' => $enterprise->getImageBase64($projectDir), 'city' => $enterprise->getCity(), 'country' => $enterprise->getCountry(), ] : null, ]; } private function serializeJobDetails($job): array { $projectDir = $this->getParameter('kernel.project_dir'); $enterprise = $job->getEnterprise(); $skills = $job->getRequiredSkills(); if (is_string($skills)) { $d = json_decode($skills, true); $skills = is_array($d) ? $d : []; } $nice = $job->getNiceToHaveSkills(); if (is_string($nice)) { $d = json_decode($nice, true); $nice = is_array($d) ? $d : []; } return [ 'id' => (string) $job->getId(), 'title' => $job->getJobTitle(), 'description' => $job->getJobSummary(), 'responsibilities' => $job->getKeyResponsabilities(), 'requirements' => $job->getRequirements(), 'skills' => $skills, 'niceToHaveSkills' => $nice, 'benefits' => $job->getBenefits(), 'category' => $job->getCategory(), 'employmentType' => $job->getEmploymentType(), 'experienceLevel' => $job->getExperienceLevel(), 'remoteWork' => $job->getRemoteWork(), 'city' => $job->getCity(), 'country' => $job->getCountry(), /* Le rattachement au référentiel — mêmes quatre champs que la liste (voir serializeJob). La fiche est LA page où le lieu canonique compte : c'est elle qui porte le JSON-LD. */ 'tradeRef' => $job->getTradeRef(), 'cityRef' => $job->getCityRef(), 'citySlug' => $job->getCitySlug(), 'areaCode' => $job->getAreaCode(), 'cityRaw' => $job->getCity(), 'salaryMin' => $job->getSalaryMin(), 'salaryMax' => $job->getSalaryMax(), 'devise' => $job->getDevise(), 'salaryPeriod' => $job->getSalaryPeriod(), 'createdAt' => $job->getCreatedAt()->format('c'), 'updatedAt' => $job->getUpdatedAt()->format('c'), 'publishedAt' => $this->etat($job)['publishedAt'], 'expiresAt' => $this->etat($job)['expiresAt'], 'expired' => $this->etat($job)['expired'], 'daysLeft' => $this->etat($job)['daysLeft'], 'closedReason' => $this->etat($job)['closedReason'], 'estimated' => $this->etat($job)['estimated'], 'liked' => false, 'websearch' => (bool) $job->isWebsearch(), 'website' => $job->getWebsite(), 'contactURL' => $job->getContactURL(), 'slug' => $job->getSlug(), 'enterprise' => $enterprise ? [ 'id' => (string) $enterprise->getId(), 'slug' => $enterprise->getSlug(), 'locale' => $enterprise->getLocale(), 'name' => $enterprise->getCompanyName(), 'logo' => $enterprise->getImageBase64($projectDir), 'category' => $enterprise->getCategory(), 'website' => $enterprise->getWebsite(), 'city' => $enterprise->getCity(), 'country' => $enterprise->getCountry(), ] : null, ]; } private function serializeEnterprise($enterprise): array { $projectDir = $this->getParameter('kernel.project_dir'); return [ 'id' => (string) $enterprise->getId(), 'name' => $enterprise->getCompanyName(), 'category' => $enterprise->getCategory(), 'shortDescription' => $enterprise->getShortDescription(), 'logo' => $enterprise->getImageBase64($projectDir), 'website' => $enterprise->getWebsite(), 'city' => $enterprise->getCity(), 'country' => $enterprise->getCountry(), /* ══════════════════════════════════════════════════════════ ⚠️ UN BOOLÉEN, PAS LE COMPTE. Le site doit savoir si quelqu'un LIT les candidatures avant de proposer d'envoyer un CV : une fiche importée sans compte rattaché n'a personne au bout, et le candidat attend une réponse qui ne viendra jamais. On rend donc `true`/`false` — jamais l'identifiant ni l'e-mail du recruteur. Exposer le compte sur un endpoint public, c'est publier une donnée personnelle pour répondre à une question qui appelle un oui ou un non. ══════════════════════════════════════════════════════════ */ 'hasAccount' => $enterprise->getUser() !== null, /* Le rattachement au référentiel — mêmes quatre champs que les offres (voir serializeJob) : le site résout les noms depuis SON référentiel, en priorité sur la saisie. */ 'tradeRef' => $enterprise->getTradeRef(), 'cityRef' => $enterprise->getCityRef(), 'citySlug' => $enterprise->getCitySlug(), 'areaCode' => $enterprise->getAreaCode(), 'cityRaw' => $enterprise->getCity(), 'slug' => $enterprise->getSlug(), 'createdAt' => $enterprise->getCreatedAt()->format('c'), 'updatedAt' => $enterprise->getUpdatedAt()->format('c'), 'liked' => false, ]; } private function serializeEnterpriseDetails($enterprise): array { $projectDir = $this->getParameter('kernel.project_dir'); $jobs = $this->entityManager->getRepository(Jobs::class) ->createQueryBuilder('j') ->where('j.enterprise = :ent') ->andWhere('j.online = :on') ->setParameter('ent', $enterprise) ->setParameter('on', true) ->orderBy('j.updatedAt', 'DESC') ->getQuery()->getResult(); $jobsData = $this->serializeJobList($jobs); return [ 'id' => (string) $enterprise->getId(), 'name' => $enterprise->getCompanyName(), 'category' => $enterprise->getCategory(), 'shortTitle' => $enterprise->getShortTitle(), 'shortDescription' => $enterprise->getShortDescription(), 'description' => $enterprise->getCompanyDescription(), 'logo' => $enterprise->getImageBase64($projectDir), 'website' => $enterprise->getWebsite(), 'email' => $enterprise->getEmail(), 'phone' => $enterprise->getPhone(), 'address' => $enterprise->getAddress(), 'city' => $enterprise->getCity(), 'zipcode' => $enterprise->getZipcode(), 'country' => $enterprise->getCountry(), /* Un compte est-il rattaché ? Voir `serializeEnterprise` : un booléen, jamais le compte lui-même. */ 'hasAccount' => $enterprise->getUser() !== null, /* Le rattachement au référentiel — la fiche entreprise affiche le lieu canonique en priorité, comme la fiche offre. */ 'tradeRef' => $enterprise->getTradeRef(), 'cityRef' => $enterprise->getCityRef(), 'citySlug' => $enterprise->getCitySlug(), 'areaCode' => $enterprise->getAreaCode(), 'cityRaw' => $enterprise->getCity(), 'slug' => $enterprise->getSlug(), 'contactName' => $enterprise->getContactName(), 'contactEmail' => $enterprise->getContactEmail(), 'contactURL' => $enterprise->getContactURL(), 'createdAt' => $enterprise->getCreatedAt()->format('c'), 'updatedAt' => $enterprise->getUpdatedAt()->format('c'), 'liked' => false, 'jobs' => $jobsData, 'jobOffersCount' => count($jobsData), ]; }}