src/Controller/Api/Cvs/PublicJobsController.php line 842

Open in your IDE?
  1. <?php
  2. namespace App\Controller\Api\Cvs;
  3. use App\Entity\Cvs\Enterprises;
  4. use App\Entity\Cvs\JobPublication;
  5. use App\Entity\Cvs\Jobs;
  6. use App\Services\RechercheReferentiel;
  7. use App\Services\Cvs\JobExpiryService;
  8. use Doctrine\ORM\EntityManagerInterface;
  9. use Doctrine\ORM\QueryBuilder;
  10. use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
  11. use Symfony\Component\HttpFoundation\JsonResponse;
  12. use Symfony\Component\HttpFoundation\Request;
  13. class PublicJobsController extends AbstractController
  14. {
  15. /**
  16. * La borne haute. Un endpoint public sans limite est une base de données
  17. * offerte à qui la demande.
  18. */
  19. private const LIMIT_MAX = 50;
  20. /** Soixante secondes. Une offre publiée n'a pas besoin d'être visible dans
  21. * la seconde ; une page d'accueil, si. */
  22. private const CACHE_SECONDS = 60;
  23. private $entityManager;
  24. private $expiry;
  25. /** La traduction « ce qu'on tape » → « identifiants du référentiel ». */
  26. private $ref;
  27. /** Le tampon des états de publication, chargé en UNE requête par liste. */
  28. private $etats = [];
  29. public function __construct(
  30. EntityManagerInterface $entityManager,
  31. JobExpiryService $expiry,
  32. RechercheReferentiel $ref
  33. ) {
  34. $this->entityManager = $entityManager;
  35. $this->expiry = $expiry;
  36. $this->ref = $ref;
  37. }
  38. // =========================================================================
  39. // HELPERS
  40. // =========================================================================
  41. /**
  42. * Borne la pagination.
  43. *
  44. * `$start` négatif ferait planter Doctrine. `$limit` non borné offrirait
  45. * toute la base en une requête.
  46. */
  47. private function clamp(int $start, int $limit): array
  48. {
  49. $start = max(0, $start);
  50. $limit = min(max(1, $limit), self::LIMIT_MAX);
  51. return [$start, $limit];
  52. }
  53. /**
  54. * @param int|null $total Le nombre total d'éléments AVANT pagination.
  55. *
  56. * ⚠️ IL PART EN EN-TÊTE, PAS DANS LE CORPS.
  57. *
  58. * Ces endpoints rendent un TABLEAU. Y glisser un `{ items, total }`
  59. * changerait la forme de la réponse pour tout le monde — dont
  60. * l'application mobile, qui itère directement dessus. Elle
  61. * cesserait d'afficher la moindre offre, et rien dans les logs de
  62. * l'API ne le dirait.
  63. *
  64. * `X-Total-Count` est la convention habituelle : les clients qui ne
  65. * la connaissent pas l'ignorent, ceux qui en ont besoin la lisent.
  66. * C'est ce qui permet à la vitrine de numéroter ses pages et de
  67. * proposer « dernière » sans changer un octet du corps.
  68. */
  69. private function cacheable(array $data, ?int $total = null): JsonResponse
  70. {
  71. $response = new JsonResponse($data);
  72. $response->setPublic();
  73. $response->setMaxAge(self::CACHE_SECONDS);
  74. $response->headers->addCacheControlDirective('must-revalidate');
  75. if ($total !== null) {
  76. $response->headers->set('X-Total-Count', (string) $total);
  77. /* Sans cette ligne, un client NAVIGATEUR ne verrait pas
  78. l'en-tête : le CORS masque tout ce qui n'est pas déclaré
  79. exposé, y compris quand la requête aboutit. La vitrine
  80. appelle depuis le serveur et n'est pas concernée — le jour
  81. où un front l'appellera, si. */
  82. $response->headers->set('Access-Control-Expose-Headers', 'X-Total-Count');
  83. }
  84. return $response;
  85. }
  86. /**
  87. * Le nombre de lignes que rendrait cette requête sans pagination.
  88. *
  89. * ⚠️ ON CLONE — LE QUERYBUILDER EST RÉUTILISÉ ENSUITE.
  90. *
  91. * Poser le `SELECT COUNT(...)` sur l'original le laisserait dans cet
  92. * état : la requête qui suit renverrait un nombre au lieu des
  93. * offres. Le clone garde les jointures et les filtres, et n'affecte
  94. * rien.
  95. *
  96. * ⚠️ ET ON NE LAISSE PAS UNE ERREUR TUER LA LISTE.
  97. *
  98. * Un `COUNT` sur une requête à jointure peut échouer là où le
  99. * `SELECT` passe. Mieux vaut une pagination sans « dernière page »
  100. * qu'une page d'erreur : on rend `null`, la vitrine retombe sur ses
  101. * boutons précédent/suivant.
  102. */
  103. private function compter(QueryBuilder $qb, string $alias): ?int
  104. {
  105. try {
  106. /* ⚠️ `COUNT(id)`, PAS `COUNT(DISTINCT id)`.
  107. `DISTINCT` force MySQL à matérialiser une table temporaire et
  108. à la trier avant de compter — sur 80 000 lignes, c'est le
  109. poste le plus cher de la requête.
  110. Et il ne sert à rien ici : la jointure est un `ManyToOne`
  111. (une offre appartient à UNE entreprise), donc elle ne peut
  112. pas multiplier les lignes. `DISTINCT` protégeait contre un
  113. risque qui n'existe pas.
  114. ⚠️ Si une jointure `OneToMany` était ajoutée un jour à
  115. `queryJobs`, il faudrait le remettre — une offre
  116. apparaîtrait alors plusieurs fois. */
  117. $compte = (clone $qb)
  118. ->select('COUNT(' . $alias . '.id)')
  119. ->resetDQLPart('orderBy')
  120. ->setFirstResult(null)
  121. ->setMaxResults(null)
  122. ->getQuery()->getSingleScalarResult();
  123. return (int) $compte;
  124. } catch (\Throwable $e) {
  125. return null;
  126. }
  127. }
  128. /**
  129. * Les filtres du corps de requête.
  130. *
  131. * Même contrat que `JobsController::extractFilters()` — le site envoie déjà
  132. * cette forme.
  133. */
  134. private function extractFilters(Request $request): array
  135. {
  136. $data = json_decode($request->getContent(), true) ?? [];
  137. /* Le site envoie `{ searchTerm, filters: {...} }` ; le mobile envoie
  138. les filtres à plat. On accepte les deux plutôt que d'imposer une
  139. migration à l'un des deux. */
  140. $filters = is_array($data['filters'] ?? null) ? $data['filters'] : $data;
  141. $search = $data['searchTerm'] ?? $data['search'] ?? '';
  142. $out = [
  143. 'search' => trim((string) $search),
  144. 'employmentType' => $this->toArray($filters['employmentType'] ?? []),
  145. 'experienceLevel' => $this->toArray($filters['experienceLevel'] ?? []),
  146. 'remoteWork' => $this->toArray($filters['remoteWork'] ?? []),
  147. 'city' => trim((string) ($filters['city'] ?? '')),
  148. 'category' => trim((string) ($filters['category'] ?? '')),
  149. /* ══════════════════════════════════════════════════════════
  150. ⚠️ CE CONTRÔLEUR A SA PROPRE COPIE DES FILTRES.
  151. J'avais corrigé ceux de `Api\Cvs\JobsController` — celui de
  152. l'espace connecté. C'est CELUI-CI que sert le site public :
  153. le filtre pays n'y était pas déclaré du tout, donc jamais lu,
  154. donc jamais appliqué.
  155. Deux copies d'une même logique, et j'ai corrigé la mauvaise.
  156. ══════════════════════════════════════════════════════════ */
  157. 'area' => trim((string) ($filters['area'] ?? '')),
  158. 'city_ref' => trim((string) ($filters['city_ref'] ?? '')),
  159. /* ⚠️ `trade_ref` N'ÉTAIT PAS DÉCLARÉ — DONC JAMAIS LU.
  160. Le site l'envoie depuis longtemps : cliquer un métier dans la
  161. sidebar pose `trade_ref` à côté de `category`. Absent de cette
  162. liste, il était écarté avant même d'atteindre le filtre, et
  163. seul le texte libre servait. Exactement le défaut corrigé pour
  164. les villes, resté en place pour les métiers. */
  165. 'trade_ref' => trim((string) ($filters['trade_ref'] ?? '')),
  166. ];
  167. /* ⚠️ JOURNAL DE DIAGNOSTIC — SUR LES FILTRES GÉOGRAPHIQUES SEULS.
  168. « Le filtre pays ne marche pas » recouvre deux situations très
  169. différentes : ou bien la vitrine n'envoie rien, ou bien l'API
  170. reçoit et n'applique pas. Sans trace, on ne peut pas trancher —
  171. et j'ai corrigé le mauvais contrôleur au tour précédent faute de
  172. cette information.
  173. On ne journalise que si un filtre géo est présent : sur chaque
  174. recherche, ce serait illisible. */
  175. if ($out['area'] !== '' || $out['city'] !== '') {
  176. error_log(sprintf(
  177. '[public/jobs] filtres géo reçus — area=%s city=%s city_ref=%s',
  178. $out['area'] ?: '(vide)',
  179. $out['city'] ?: '(vide)',
  180. $out['city_ref'] ?: '(vide)'
  181. ));
  182. }
  183. return $out;
  184. }
  185. /**
  186. * Une valeur qui doit être un tableau.
  187. *
  188. * Un client peut envoyer `employmentType: "cdi"` au lieu de `["cdi"]`. Un
  189. * `IN (:x)` avec une chaîne fait planter Doctrine. On normalise plutôt que
  190. * de compter sur la discipline de l'appelant.
  191. */
  192. private function toArray($value): array
  193. {
  194. if (is_array($value)) {
  195. return array_values(array_filter($value, static fn($v) => $v !== '' && $v !== null));
  196. }
  197. if (is_string($value) && $value !== '') {
  198. return [$value];
  199. }
  200. return [];
  201. }
  202. private function applyJobFilters(QueryBuilder $qb, array $filters): QueryBuilder
  203. {
  204. /* ══════════════════════════════════════════════════════════════
  205. LA RECHERCHE TEXTE — ET LE RÉFÉRENTIEL
  206. ⚠️ CHERCHER « NEW YORK » NE TROUVAIT PAS LES OFFRES NEW-YORKAISES.
  207. La clause ne comparait que du texte libre. Or une offre
  208. rattachée à New York porte « NY, USA », « NYC » ou
  209. « Remote/Hybrid (US-based) » dans sa colonne `city` — c'est
  210. exactement pour ça que le référentiel existe. Le
  211. rattachement était fait, l'écran de correction affichait
  212. 100 %, et la recherche restait vide.
  213. Même défaut pour un pays : « France » ne trouvait rien quand
  214. `country` était vide alors que `area_code` valait « FR ». Et
  215. pour un métier : « Ressources humaines » ne remontait pas les
  216. offres stockées « Human Resources ».
  217. ⚠️ ON AJOUTE, ON NE REMPLACE PAS.
  218. Le texte libre reste comparé : les offres non rattachées
  219. doivent continuer de sortir. Ces conditions viennent en `OR`
  220. — élargir la recherche, jamais la restreindre.
  221. ⚠️ ET SEULEMENT SUR UNE CORRESPONDANCE EXACTE.
  222. `resoudreTerme` n'accepte pas l'à-peu-près : « data » ne doit
  223. pas être pris pour une ville, sinon la recherche texte se
  224. transformerait en filtre géographique et raterait tout le
  225. reste.
  226. ══════════════════════════════════════════════════════════════ */
  227. if (!empty($filters['search'])) {
  228. $terme = (string) $filters['search'];
  229. $vu = $this->ref->resoudreTerme($terme);
  230. $clauses = [
  231. 'LOWER(j.jobTitle) LIKE :search',
  232. 'LOWER(j.category) LIKE :search',
  233. 'LOWER(j.city) LIKE :search',
  234. 'LOWER(j.shortDescription) LIKE :search',
  235. 'LOWER(e.companyName) LIKE :search',
  236. ];
  237. $params = ['search' => '%' . strtolower($terme) . '%'];
  238. if ($vu['citySlug'] !== null) {
  239. $clauses[] = 'j.citySlug = :searchCitySlug';
  240. $params['searchCitySlug'] = $vu['citySlug'];
  241. }
  242. if ($vu['areaCode'] !== null) {
  243. /* Les régions comprises : taper « France » doit remonter
  244. une offre rattachée à « FR-IDF ». */
  245. $clauses[] = 'j.areaCode = :searchArea';
  246. $clauses[] = 'j.areaCode LIKE :searchAreaSub';
  247. $params['searchArea'] = $vu['areaCode'];
  248. $params['searchAreaSub'] = $vu['areaCode'] . '-%';
  249. }
  250. if ($vu['tradeRefs']) {
  251. $clauses[] = 'j.tradeRef IN (:searchTradeRefs)';
  252. $params['searchTradeRefs'] = $vu['tradeRefs'];
  253. }
  254. $qb->andWhere(implode(' OR ', $clauses));
  255. foreach ($params as $k => $v) {
  256. $qb->setParameter($k, $v);
  257. }
  258. }
  259. if (!empty($filters['employmentType'])) {
  260. $qb->andWhere('j.employmentType IN (:empTypes)')
  261. ->setParameter('empTypes', $filters['employmentType']);
  262. }
  263. if (!empty($filters['experienceLevel'])) {
  264. $qb->andWhere('j.experienceLevel IN (:expLevels)')
  265. ->setParameter('expLevels', $filters['experienceLevel']);
  266. }
  267. if (!empty($filters['remoteWork'])) {
  268. $qb->andWhere('j.remoteWork IN (:remoteOpts)')
  269. ->setParameter('remoteOpts', $filters['remoteWork']);
  270. }
  271. /* ══════════════════════════════════════════════════════════════
  272. LA VILLE, PAR LE RÉFÉRENTIEL
  273. ⚠️ ON NE COMPARE PLUS LE TEXTE LIBRE.
  274. `LIKE '%paris%'` remontait « Paris, Texas » et ratait
  275. « Paris 15 ». Le slug est exact et porte sa zone.
  276. Une offre non rattachée ne sort plus des filtres ville :
  277. c'est le prix de la précision, et l'écran « Correction city »
  278. du backoffice sert à les rattacher.
  279. ══════════════════════════════════════════════════════════════ */
  280. /* ══════════════════════════════════════════════════════════════
  281. ⚠️ SLUG **OU** RÉFÉRENCE, PAS LES DEUX À LA FOIS.
  282. Les deux étaient posés en `AND` : une offre devait porter le slug
  283. ET la référence pour sortir. Or ce sont deux populations
  284. distinctes — celles rattachées avant la migration n'ont que le
  285. slug, celles rattachées depuis ont les deux.
  286. En `AND`, la moitié disparaissait. En `OR`, une ville désignée
  287. par n'importe lequel des deux identifiants remonte ses offres.
  288. La référence d'abord : elle survit à un renommage, le slug non.
  289. ══════════════════════════════════════════════════════════════ */
  290. $ville = !empty($filters['city']) ? strtolower(trim($filters['city'])) : null;
  291. $villeRef = !empty($filters['city_ref']) ? trim($filters['city_ref']) : null;
  292. /* ⚠️ UNE RÉFÉRENCE DÉSIGNE UNE LANDING, LE SLUG DÉSIGNE LA VILLE.
  293. Le hero envoie la référence de la landing affichée — celle du
  294. public candidat, ou celle des entreprises selon la page. L'offre,
  295. elle, peut porter n'importe laquelle des seize.
  296. On traduit donc la référence en SLUG avant de comparer : c'est le
  297. seul identifiant commun aux deux. Sans ça, une offre rattachée
  298. côté entreprise ne sortait pas d'une recherche candidat, et
  299. inversement. */
  300. if ($villeRef !== null && $ville === null) {
  301. try {
  302. $slugDeRef = $this->entityManager->getConnection()->fetchOne(
  303. 'SELECT slug FROM bo_geo_city_refs WHERE ref = :r LIMIT 1',
  304. ['r' => $villeRef]
  305. );
  306. if ($slugDeRef) {
  307. $ville = strtolower((string) $slugDeRef);
  308. }
  309. } catch (\Throwable $e) {
  310. /* Table absente : on compare la référence telle quelle. */
  311. }
  312. }
  313. if ($ville !== null || $villeRef !== null) {
  314. $ou = [];
  315. $params = [];
  316. if ($villeRef !== null) {
  317. $ou[] = 'j.cityRef = :filterCityRef';
  318. $params['filterCityRef'] = $villeRef;
  319. }
  320. if ($ville !== null) {
  321. $ou[] = 'j.citySlug = :filterCity';
  322. $params['filterCity'] = $ville;
  323. }
  324. /* ══════════════════════════════════════════════════════════
  325. ⚠️ LES OFFRES NON RATTACHÉES NE DISPARAISSENT PLUS.
  326. Le filtre ne comparait que le rattachement : une offre dont
  327. la ville n'est que du texte — le cas de la plupart des
  328. offres importées — ne sortait d'AUCUN filtre par ville. Sur
  329. une page « Offres à Bruxelles », on lisait « aucun résultat »
  330. alors que la base en contenait, simplement pas rattachées.
  331. On les rattrape par leur nom, en ÉGALITÉ STRICTE et non en
  332. `LIKE` : c'est ce qui évite le retour du défaut d'origine —
  333. « Paris » attrapant « Paris, Texas ». « Paris 15 » reste
  334. dehors, et c'est le rattachement qui règle ce cas-là.
  335. La condition ne porte que sur les offres SANS rattachement :
  336. une offre rattachée ailleurs ne doit pas remonter ici parce
  337. que son texte libre dit autre chose que sa fiche.
  338. ══════════════════════════════════════════════════════════ */
  339. $nomVille = $this->ref->nomDeLaVille($ville ?? '');
  340. if ($nomVille !== null || $ville !== null) {
  341. $ou[] = "(j.citySlug IS NULL OR j.citySlug = '') "
  342. /* ⚠️ PAS DE `LOWER()` SUR LA COLONNE.
  343. Enveloppée dans une fonction, la colonne n'est plus
  344. comparable à l'index : MySQL ne peut plus descendre
  345. dans l'arbre et relit les 80 000 lignes.
  346. Et c'est inutile ici : la table est en
  347. `utf8mb4_unicode_ci`, dont la comparaison est DÉJÀ
  348. insensible à la casse. `LOWER()` ne changeait donc
  349. rien au résultat — seulement au plan d'exécution. */
  350. . 'AND (j.city = :filterCityName OR j.city = :filterCityRaw)';
  351. $params['filterCityName'] = mb_strtolower((string) ($nomVille ?? $ville));
  352. $params['filterCityRaw'] = (string) $ville;
  353. }
  354. $qb->andWhere('(' . implode(') OR (', $ou) . ')');
  355. foreach ($params as $k => $v) {
  356. $qb->setParameter($k, $v);
  357. }
  358. }
  359. /* ⚠️ LE PAYS, RÉGIONS COMPRISES.
  360. Demander « CH » doit remonter les offres rattachées à une région
  361. suisse. Sans le second motif, seules celles rattachées au pays
  362. entier sortiraient — et une ville placée sous sa région
  363. disparaîtrait du filtre de son propre pays. */
  364. if (!empty($filters['area'])) {
  365. $area = strtoupper(trim($filters['area']));
  366. $ouZone = ['j.areaCode = :filterArea', 'j.areaCode LIKE :filterAreaSub'];
  367. /* ⚠️ UNE OFFRE PEUT AVOIR SA VILLE SANS AVOIR SA ZONE.
  368. `area_code` est dérivé de la ville à l'enregistrement — donc
  369. présent sur tout ce qui a été rattaché depuis cette règle.
  370. Restent les anciennes : une ville française renseignée, une
  371. zone vide, et une invisibilité totale dans le filtre pays.
  372. On ne renvoie pas les six cents villes du pays dans un `IN`
  373. (c'est la solution précédente, abandonnée pour son poids) :
  374. seulement les slugs RÉELLEMENT portés par des offres
  375. orphelines. En base saine, la liste est vide et la clause
  376. n'est pas posée. */
  377. $orphelines = $this->ref->villesOrphelinesDeLaZone($area, 'cvs_jobs');
  378. if ($orphelines) {
  379. $ouZone[] = 'j.citySlug IN (:filterAreaCities)';
  380. }
  381. $qb->andWhere(implode(' OR ', $ouZone))
  382. ->setParameter('filterArea', $area)
  383. ->setParameter('filterAreaSub', $area . '-%');
  384. if ($orphelines) {
  385. $qb->setParameter('filterAreaCities', $orphelines);
  386. }
  387. }
  388. /* Les offres expirées sortent des résultats publics : elles restent
  389. accessibles par leur URL, mais n'encombrent pas une recherche. */
  390. $qb->andWhere('j.isExpired = false OR j.isExpired IS NULL');
  391. /* ══════════════════════════════════════════════════════════════
  392. LE MÉTIER — RÉFÉRENCE **OU** TEXTE LIBRE
  393. ⚠️ MÊME RAISONNEMENT QUE POUR LES VILLES, MÊME `OR`.
  394. Deux populations cohabitent : les offres rattachées au
  395. référentiel (`trade_ref`) et celles dont seule la catégorie
  396. en texte libre est renseignée. En `AND`, chaque filtre en
  397. perdrait une moitié ; en `OR`, un métier désigné par l'un ou
  398. l'autre identifiant remonte ses offres.
  399. ⚠️ UNE RÉFÉRENCE DÉSIGNE UNE PUCE, PAS LE MÉTIER.
  400. Un métier existe en huit langues côté vitrine, donc jusqu'à
  401. huit références pour la même chose. On traduit la référence
  402. reçue en SLUG, puis on accepte toutes les références qui
  403. partagent ce slug — sans quoi une offre rattachée depuis la
  404. page anglaise ne sortirait pas d'une recherche française.
  405. ══════════════════════════════════════════════════════════════ */
  406. $refsMetier = $this->ref->referencesMetierFreres(
  407. !empty($filters['trade_ref']) ? trim($filters['trade_ref']) : null
  408. );
  409. if (!empty($filters['category']) && $refsMetier) {
  410. $qb->andWhere('j.tradeRef IN (:filterTradeRefs) OR LOWER(j.category) LIKE :filterCat')
  411. ->setParameter('filterTradeRefs', $refsMetier)
  412. ->setParameter('filterCat', '%' . strtolower($filters['category']) . '%');
  413. } elseif ($refsMetier) {
  414. $qb->andWhere('j.tradeRef IN (:filterTradeRefs)')
  415. ->setParameter('filterTradeRefs', $refsMetier);
  416. } elseif (!empty($filters['category'])) {
  417. $qb->andWhere('LOWER(j.category) LIKE :filterCat')
  418. ->setParameter('filterCat', '%' . strtolower($filters['category']) . '%');
  419. }
  420. return $qb;
  421. }
  422. private function applyEnterpriseFilters(QueryBuilder $qb, array $filters): QueryBuilder
  423. {
  424. /* Même correction que les offres : chercher « New York » doit
  425. remonter les fiches rattachées à new-york, quel que soit le
  426. texte libre de leur colonne `city`. Deux annuaires qui
  427. cherchent différemment donneraient des résultats incohérents
  428. pour la même intention. */
  429. if (!empty($filters['search'])) {
  430. $terme = (string) $filters['search'];
  431. $vu = $this->ref->resoudreTerme($terme);
  432. $clauses = [
  433. 'LOWER(e.companyName) LIKE :search',
  434. 'LOWER(e.category) LIKE :search',
  435. 'LOWER(e.city) LIKE :search',
  436. 'LOWER(e.shortDescription) LIKE :search',
  437. ];
  438. $params = ['search' => '%' . strtolower($terme) . '%'];
  439. if ($vu['citySlug'] !== null) {
  440. $clauses[] = 'e.citySlug = :searchCitySlugE';
  441. $params['searchCitySlugE'] = $vu['citySlug'];
  442. }
  443. if ($vu['areaCode'] !== null) {
  444. $clauses[] = 'e.areaCode = :searchAreaE';
  445. $clauses[] = 'e.areaCode LIKE :searchAreaSubE';
  446. $params['searchAreaE'] = $vu['areaCode'];
  447. $params['searchAreaSubE'] = $vu['areaCode'] . '-%';
  448. }
  449. if ($vu['tradeRefs']) {
  450. $clauses[] = 'e.tradeRef IN (:searchTradeRefsE)';
  451. $params['searchTradeRefsE'] = $vu['tradeRefs'];
  452. }
  453. $qb->andWhere(implode(' OR ', $clauses));
  454. foreach ($params as $k => $v) {
  455. $qb->setParameter($k, $v);
  456. }
  457. }
  458. /* ⚠️ MÊME RÈGLE QUE POUR LES OFFRES.
  459. L'annuaire filtrait les entreprises sur le texte libre, avec les
  460. mêmes défauts : « Paris » attrapait « Paris, Texas ». Laisser les
  461. deux écrans diverger donnerait des résultats différents pour la
  462. même intention — et l'on ne saurait pas lequel croire. */
  463. /* ══════════════════════════════════════════════════════════════
  464. ⚠️ SLUG **OU** RÉFÉRENCE, COMME POUR LES OFFRES.
  465. Les entreprises ne filtraient que par slug : une fiche rattachée
  466. par référence seule ne sortait pas. Deux règles différentes entre
  467. les deux annuaires donneraient des résultats incohérents pour la
  468. même ville — et l'on ne saurait pas lequel croire.
  469. ══════════════════════════════════════════════════════════════ */
  470. $villeE = !empty($filters['city']) ? strtolower(trim($filters['city'])) : null;
  471. $villeRefE = !empty($filters['city_ref']) ? trim($filters['city_ref']) : null;
  472. if ($villeE !== null || $villeRefE !== null) {
  473. $ouE = [];
  474. $paramsE = [];
  475. if ($villeRefE !== null) {
  476. $ouE[] = 'e.cityRef = :filterCityRefE';
  477. $paramsE['filterCityRefE'] = $villeRefE;
  478. }
  479. if ($villeE !== null) {
  480. $ouE[] = 'e.citySlug = :filterCityE';
  481. $paramsE['filterCityE'] = $villeE;
  482. }
  483. /* Même repli que pour les offres : une fiche non rattachée
  484. reste trouvable par son nom de ville, en égalité stricte. */
  485. $nomVilleE = $this->ref->nomDeLaVille($villeE ?? '');
  486. if ($nomVilleE !== null || $villeE !== null) {
  487. $ouE[] = "(e.citySlug IS NULL OR e.citySlug = '') "
  488. /* Même raison que pour les offres : la collation rend
  489. déjà la comparaison insensible à la casse, et
  490. `LOWER()` interdisait l'index. */
  491. . 'AND (e.city = :filterCityNameE OR e.city = :filterCityRawE)';
  492. $paramsE['filterCityNameE'] = mb_strtolower((string) ($nomVilleE ?? $villeE));
  493. $paramsE['filterCityRawE'] = (string) $villeE;
  494. }
  495. $qb->andWhere('(' . implode(') OR (', $ouE) . ')');
  496. foreach ($paramsE as $k => $v) {
  497. $qb->setParameter($k, $v);
  498. }
  499. }
  500. if (!empty($filters['area'])) {
  501. $area = strtoupper(trim($filters['area']));
  502. $ouZoneE = ['e.areaCode = :filterArea', 'e.areaCode LIKE :filterAreaSub'];
  503. /* Même rattrapage que les offres : ville rattachée, zone vide. */
  504. $orphelinesE = $this->ref->villesOrphelinesDeLaZone($area, 'cvs_enterprises');
  505. if ($orphelinesE) {
  506. $ouZoneE[] = 'e.citySlug IN (:filterAreaCitiesE)';
  507. }
  508. $qb->andWhere(implode(' OR ', $ouZoneE))
  509. ->setParameter('filterArea', $area)
  510. ->setParameter('filterAreaSub', $area . '-%');
  511. if ($orphelinesE) {
  512. $qb->setParameter('filterAreaCitiesE', $orphelinesE);
  513. }
  514. }
  515. /* Le secteur : même règle que les offres — référence du référentiel
  516. OU texte libre, en `OR`. Deux annuaires qui filtrent différemment
  517. donneraient des résultats incohérents pour la même intention. */
  518. $refsSecteur = $this->ref->referencesMetierFreres(
  519. !empty($filters['trade_ref']) ? trim($filters['trade_ref']) : null
  520. );
  521. if (!empty($filters['category']) && $refsSecteur) {
  522. $qb->andWhere('e.tradeRef IN (:filterTradeRefsE) OR LOWER(e.category) LIKE :filterCatE')
  523. ->setParameter('filterTradeRefsE', $refsSecteur)
  524. ->setParameter('filterCatE', '%' . strtolower($filters['category']) . '%');
  525. } elseif ($refsSecteur) {
  526. $qb->andWhere('e.tradeRef IN (:filterTradeRefsE)')
  527. ->setParameter('filterTradeRefsE', $refsSecteur);
  528. } elseif (!empty($filters['category'])) {
  529. $qb->andWhere('LOWER(e.category) LIKE :filterCatE')
  530. ->setParameter('filterCatE', '%' . strtolower($filters['category']) . '%');
  531. }
  532. return $qb;
  533. }
  534. // =========================================================================
  535. // REQUÊTES
  536. // =========================================================================
  537. /**
  538. * Les offres en ligne.
  539. *
  540. * `(e.id IS NULL OR e.online = true)` : une offre sans entreprise reliée est
  541. * légitime (import websearch). Une offre reliée à une entreprise hors ligne
  542. * ne doit pas s'afficher — c'est la règle de `JobsController`, on la garde.
  543. */
  544. private function queryJobs(): QueryBuilder
  545. {
  546. $qb = $this->entityManager->getRepository(Jobs::class)
  547. ->createQueryBuilder('j')
  548. ->leftJoin('j.enterprise', 'e')
  549. ->where('j.online = :online')
  550. ->andWhere('(e.id IS NULL OR e.online = :online)')
  551. ->setParameter('online', true)
  552. ->orderBy('j.updatedAt', 'DESC');
  553. return $this->excludeExpired($qb);
  554. }
  555. /**
  556. * ═══════════════════════════════════════════════════════════════════════
  557. * ⚠️ ON N'EXCLUT QUE CE QU'ON SAIT EXPIRÉ. PAS CE QU'ON SUPPOSE.
  558. * ═══════════════════════════════════════════════════════════════════════
  559. *
  560. * La base contient des dizaines de milliers d'offres publiées AVANT que la
  561. * table `cvs_job_publication` n'existe. Aucune n'a de date de publication
  562. * enregistrée : on la CALCULE depuis `createdAt`, et beaucoup ont plus de
  563. * 90 jours.
  564. *
  565. * Les exclure toutes viderait le site du jour au lendemain. Ce n'est pas
  566. * une décision technique — c'est une décision de produit, et elle
  567. * n'appartient pas à ce contrôleur.
  568. *
  569. * Donc :
  570. *
  571. * · Offre AVEC une publication enregistrée et expirée → EXCLUE
  572. * · Offre AVEC une publication fermée (pourvue/retirée) → EXCLUE
  573. * · Offre SANS publication (héritée) → GARDÉE
  574. *
  575. * L'expiration s'applique donc à partir d'aujourd'hui, pour les offres
  576. * dont on connaît la vraie date. Le passé n'est pas réécrit.
  577. *
  578. * Le README contient la requête pour compter combien d'offres héritées
  579. * auraient plus de 90 jours, et le script de rattrapage à lancer LE JOUR
  580. * OÙ TU AURAS VU LE CHIFFRE.
  581. *
  582. * ⚠️ Cette liste reste visible sur la page de l'offre : `expired: true` y
  583. * est renvoyé, et le site affiche « cette offre a expiré ». On sort des
  584. * listes, on ne fait pas disparaître la page — Google et les liens en
  585. * circulation attendent qu'elle réponde.
  586. */
  587. private function excludeExpired(QueryBuilder $qb): QueryBuilder
  588. {
  589. /* Une sous-requête plutôt qu'une jointure : on veut « il n'existe PAS
  590. de publication expirée pour cette offre », ce qui inclut « il n'existe
  591. aucune publication du tout ». Une jointure LEFT + IS NULL dirait la
  592. même chose, mais moins clairement. */
  593. /* ⚠️ `::class`, PAS UNE CHAÎNE. J'AVAIS ÉCRIT :
  594. ->from('App\\\\Entity\\\\Cvs\\\\JobPublication', 'pub')
  595. En chaîne SIMPLE, chaque `\\\\` donne `\\`. Doctrine cherchait donc une
  596. entité nommée `App\\Entity\\Cvs\\JobPublication` — avec des DOUBLES
  597. antislashs — et ne trouvait rien. Erreur à la première requête, pas à
  598. la compilation : invisible jusqu'en production.
  599. `::class` ne peut pas se tromper d'échappement, et il suit les
  600. renommages.
  601. ⚠️ Et `SELECT 1` en DQL est fragile : selon la version de Doctrine, le
  602. parseur l'accepte ou le refuse. `pub.id` est toujours valide. */
  603. $sub = $this->entityManager->createQueryBuilder()
  604. ->select('pub.id')
  605. ->from(JobPublication::class, 'pub')
  606. ->where('pub.job = j')
  607. ->andWhere('(pub.expiresAt < :maintenant OR pub.closedReason IS NOT NULL)')
  608. ->getDQL();
  609. return $qb
  610. ->andWhere('NOT EXISTS (' . $sub . ')')
  611. ->setParameter('maintenant', new \DateTime());
  612. }
  613. /**
  614. * L'état d'une offre, depuis le tampon s'il est chargé.
  615. *
  616. * Sur un DÉTAIL (une seule offre), le tampon est vide : on fait la requête.
  617. * Sur une LISTE, `serializeJobList()` l'a déjà rempli en une fois.
  618. */
  619. private function etat(Jobs $job): array
  620. {
  621. $id = $job->getId();
  622. if (!isset($this->etats[$id])) {
  623. $this->etats[$id] = $this->expiry->state($job);
  624. }
  625. return $this->etats[$id];
  626. }
  627. /**
  628. * GET /api/public/jobs/sitemap/{start}/{limit}
  629. *
  630. * ══════════════════════════════════════════════════════════════════
  631. * LES OFFRES POUR LE PLAN DU SITE — identifiant, slug, date. Rien d'autre.
  632. *
  633. * ⚠️ POURQUOI UN ENDPOINT À PART.
  634. *
  635. * La liste publique plafonne à 50 offres par appel — la bonne
  636. * valeur pour un écran, absurde pour un plan de site : 80 000
  637. * offres demanderaient 1 600 allers-retours HTTP.
  638. *
  639. * Et elle renvoie la fiche entière : titre, description, salaire,
  640. * entreprise. Un plan de site n'a besoin que de l'URL et de la
  641. * date. Sur 80 000 lignes, l'écart se compte en centaines de
  642. * mégaoctets transférés pour rien.
  643. *
  644. * Ici : trois colonnes, 5 000 lignes par appel. Seize appels
  645. * suffisent au catalogue entier.
  646. *
  647. * ⚠️ `getArrayResult`, PAS `getResult`.
  648. *
  649. * Hydrater 5 000 entités Doctrine pour lire trois champs sature
  650. * la mémoire de PHP. Le tableau brut ne construit aucun objet.
  651. *
  652. * ⚠️ ET LE TRI EST SUR L'IDENTIFIANT, PAS SUR LA DATE.
  653. *
  654. * Une pagination par `OFFSET` sur un tri par date de mise à jour
  655. * n'est pas stable : une offre modifiée entre deux appels change
  656. * de place, et se retrouve lue deux fois — ou jamais. Sur un plan
  657. * de site, c'est une URL manquante qu'on ne remarque pas.
  658. * ══════════════════════════════════════════════════════════════════
  659. */
  660. public function sitemapJobs(int $start, int $limit): JsonResponse
  661. {
  662. /* Cinq mille : au-delà, la réponse JSON dépasse quelques mégaoctets
  663. et le gain d'un appel de moins ne compense plus. */
  664. $limit = min(max(1, $limit), 5000);
  665. $start = max(0, $start);
  666. $lignes = $this->queryJobs()
  667. ->select('j.id, j.slug, j.updatedAt')
  668. ->resetDQLPart('orderBy')
  669. ->orderBy('j.id', 'ASC')
  670. ->setFirstResult($start)
  671. ->setMaxResults($limit)
  672. ->getQuery()->getArrayResult();
  673. $jobs = array_map(static function (array $j): array {
  674. return [
  675. 'id' => (int) $j['id'],
  676. 'slug' => $j['slug'],
  677. 'updatedAt' => $j['updatedAt'] instanceof \DateTimeInterface
  678. ? $j['updatedAt']->format('Y-m-d')
  679. : null,
  680. ];
  681. }, $lignes);
  682. /* Le total accompagne chaque page : le client sait combien d'appels
  683. il lui reste sans avoir à deviner ni à boucler jusqu'au vide. */
  684. return $this->cacheable($jobs, $this->compter($this->queryJobs(), 'j'));
  685. }
  686. private function queryEnterprises(): QueryBuilder
  687. {
  688. return $this->entityManager->getRepository(Enterprises::class)
  689. ->createQueryBuilder('e')
  690. ->where('e.online = :online')
  691. ->setParameter('online', true)
  692. ->orderBy('e.updatedAt', 'DESC');
  693. }
  694. // =========================================================================
  695. // OFFRES
  696. // =========================================================================
  697. public function jobs(int $start, int $limit): JsonResponse
  698. {
  699. [$start, $limit] = $this->clamp($start, $limit);
  700. $qb = $this->queryJobs();
  701. $total = $this->compter($qb, 'j');
  702. $jobs = $qb->setFirstResult($start)->setMaxResults($limit)
  703. ->getQuery()->getResult();
  704. return $this->cacheable($this->serializeJobList($jobs), $total);
  705. }
  706. public function searchJobs(Request $request, int $start, int $limit): JsonResponse
  707. {
  708. [$start, $limit] = $this->clamp($start, $limit);
  709. $qb = $this->applyJobFilters($this->queryJobs(), $this->extractFilters($request));
  710. $total = $this->compter($qb, 'j');
  711. $jobs = $qb->setFirstResult($start)->setMaxResults($limit)
  712. ->getQuery()->getResult();
  713. return $this->cacheable($this->serializeJobList($jobs), $total);
  714. }
  715. public function job(int $id): JsonResponse
  716. {
  717. $job = $this->entityManager->getRepository(Jobs::class)->find($id);
  718. /* ⚠️ UNE OFFRE HORS LIGNE N'EXISTE PAS POUR UN ANONYME.
  719. Renvoyer une 403 dirait « elle existe, mais tu n'y as pas droit » —
  720. ce qui permet d'énumérer les offres dépubliées. 404 : elle n'existe
  721. pas, point. */
  722. if (!$job || !$job->isOnline()) {
  723. return new JsonResponse(['error' => 'NOT_FOUND'], 404);
  724. }
  725. $enterprise = $job->getEnterprise();
  726. if ($enterprise && !$enterprise->isOnline()) {
  727. return new JsonResponse(['error' => 'NOT_FOUND'], 404);
  728. }
  729. return $this->cacheable($this->serializeJobDetails($job));
  730. }
  731. /**
  732. * Un PAQUET ALÉATOIRE d'offres — pour le mode swipe.
  733. *
  734. * Le site présente les offres en pile qu'on fait défiler : on veut des
  735. * paquets de N offres, variés à chaque appel, sans que le client ait à
  736. * paginer. On ne demande pas `RAND()` à MySQL (un tri aléatoire sur toute
  737. * la table est coûteux) : on tire un LOT plus large des offres récentes,
  738. * on le mélange en PHP, on en garde N. C'est aléatoire « assez » pour
  739. * l'usage, et ça reste une seule requête bornée.
  740. *
  741. * `exclude` (optionnel, query string, ids séparés par des virgules) retire
  742. * l'offre déjà affichée et celles déjà vues, pour éviter les répétitions.
  743. */
  744. public function randomJobs(Request $request, int $count): JsonResponse
  745. {
  746. $count = max(1, min($count, self::LIMIT_MAX));
  747. // On pioche dans un vivier plus large que N, puis on mélange.
  748. $vivier = min(self::LIMIT_MAX, max($count * 4, 40));
  749. $qb = $this->queryJobs();
  750. /* ══════════════════════════════════════════════════════════════
  751. ⚠️ `?rattachees=1` — SEULEMENT LES OFFRES RELIÉES AU RÉFÉRENTIEL.
  752. L'accueil montre ces offres avec leur ville. Une offre non
  753. rattachée y affiche « NY, USA » ou « Remote/Hybrid (US-based) »,
  754. c'est-à-dire la saisie brute du recruteur, à côté d'offres qui
  755. annoncent proprement « New York ». Sur la page la plus vue du
  756. site, l'incohérence saute aux yeux.
  757. Ce filtre ne corrige rien — il choisit de ne montrer que ce qui
  758. est présentable.
  759. ══════════════════════════════════════════════════════════════ */
  760. if ($request->query->get('rattachees') === '1') {
  761. $qb->andWhere("j.cityRef IS NOT NULL AND j.cityRef <> ''");
  762. }
  763. /* ══════════════════════════════════════════════════════════════
  764. ⚠️ UN DÉCALAGE ALÉATOIRE, PAS TOUJOURS LES PLUS RÉCENTES.
  765. Le vivier était pris en tête de la liste triée par date : on
  766. mélangeait donc les quarante dernières offres, encore et encore.
  767. Deux visites à dix minutes d'intervalle montraient les mêmes
  768. annonces dans un ordre différent — ce qui se voit, et donne
  769. l'impression d'un catalogue étroit.
  770. On tire maintenant une TRANCHE au hasard dans l'ensemble. Un
  771. `ORDER BY RAND()` sur quatre-vingt mille lignes trierait toute la
  772. table à chaque appel ; un `COUNT` puis un décalage coûtent deux
  773. requêtes bornées, et la variété est réelle.
  774. ⚠️ LE DÉCALAGE EST BORNÉ PAR LE TOTAL MOINS LE VIVIER.
  775. Sinon une offre en fin de liste sortirait un vivier de trois
  776. lignes, et l'accueil afficherait trois offres au lieu de huit
  777. sans que rien ne le signale.
  778. ══════════════════════════════════════════════════════════════ */
  779. $total = $this->compter($qb, 'j');
  780. if ($total !== null && $total > $vivier) {
  781. $qb->setFirstResult(random_int(0, $total - $vivier));
  782. }
  783. $jobs = $qb
  784. ->setMaxResults($vivier)
  785. ->getQuery()->getResult();
  786. // Exclusions (offre courante + déjà vues).
  787. $exclude = array_filter(array_map('trim', explode(',', (string) $request->query->get('exclude', ''))));
  788. if ($exclude) {
  789. $set = array_flip($exclude);
  790. $jobs = array_values(array_filter($jobs, static function ($j) use ($set) {
  791. return !isset($set[(string) $j->getId()]);
  792. }));
  793. }
  794. shuffle($jobs);
  795. $jobs = array_slice($jobs, 0, $count);
  796. return $this->cacheable($this->serializeJobList($jobs));
  797. }
  798. /**
  799. * LES FACETTES — villes et catégories reliées aux offres en ligne.
  800. *
  801. * L'ancienne sidebar du site vitrine listait les villes et les catégories
  802. * de jobs, paginées. On les calcule ici, avec le nombre d'offres par
  803. * valeur (utile pour trier et afficher un compteur). Deux requêtes
  804. * agrégées, pas de N+1. On ne renvoie que ce qui a au moins une offre.
  805. */
  806. public function jobFacets(): JsonResponse
  807. {
  808. $cities = $this->entityManager->getRepository(Jobs::class)
  809. ->createQueryBuilder('j')
  810. ->select('j.city AS value, COUNT(j.id) AS total')
  811. ->leftJoin('j.enterprise', 'e')
  812. ->where('j.online = :on')
  813. ->andWhere('(e.id IS NULL OR e.online = :on)')
  814. ->andWhere('j.city IS NOT NULL')
  815. ->andWhere("j.city <> ''")
  816. ->setParameter('on', true)
  817. ->groupBy('j.city')
  818. ->orderBy('total', 'DESC')
  819. ->getQuery()->getArrayResult();
  820. $categories = $this->entityManager->getRepository(Jobs::class)
  821. ->createQueryBuilder('j')
  822. ->select('j.category AS value, COUNT(j.id) AS total')
  823. ->leftJoin('j.enterprise', 'e')
  824. ->where('j.online = :on')
  825. ->andWhere('(e.id IS NULL OR e.online = :on)')
  826. ->andWhere('j.category IS NOT NULL')
  827. ->andWhere("j.category <> ''")
  828. ->setParameter('on', true)
  829. ->groupBy('j.category')
  830. ->orderBy('total', 'DESC')
  831. ->getQuery()->getArrayResult();
  832. $map = static function (array $rows): array {
  833. return array_map(static function ($r) {
  834. return ['value' => $r['value'], 'count' => (int) $r['total']];
  835. }, $rows);
  836. };
  837. return $this->cacheable([
  838. 'cities' => $map($cities),
  839. 'categories' => $map($categories),
  840. ]);
  841. }
  842. /**
  843. * Le détail d'une offre PAR SON SLUG.
  844. *
  845. * Le site public expose les offres sous /job/{slug} : une URL lisible et
  846. * stable, meilleure pour le référencement qu'un identifiant numérique. On
  847. * retrouve donc l'offre par `slug`. Mêmes règles que `job()` : une offre
  848. * hors ligne, ou dont l'entreprise est hors ligne, n'existe pas (404) — on
  849. * n'énumère rien.
  850. *
  851. * Un `slug` peut, en théorie, ne pas être unique (rien ne l'impose en
  852. * base). On prend la plus récente en ligne : c'est celle que le site vient
  853. * de lister.
  854. */
  855. public function jobBySlug(string $slug): JsonResponse
  856. {
  857. $job = $this->entityManager->getRepository(Jobs::class)
  858. ->createQueryBuilder('j')
  859. ->where('j.slug = :slug')
  860. ->andWhere('j.online = :online')
  861. ->setParameter('slug', $slug)
  862. ->setParameter('online', true)
  863. ->orderBy('j.updatedAt', 'DESC')
  864. ->setMaxResults(1)
  865. ->getQuery()->getOneOrNullResult();
  866. if (!$job) {
  867. /* ══════════════════════════════════════════════════════════════
  868. ⚠️ 410 SI L'OFFRE A EXISTÉ, 404 SI ELLE N'A JAMAIS EXISTÉ.
  869. La requête ci-dessus exige `online = true` : une offre
  870. dépubliée ou expirée en sort, et rendait 404 — le même code
  871. qu'une adresse inventée.
  872. Or les deux ne disent pas la même chose à un robot
  873. d'indexation. « 404 » signifie « pas là, réessaie plus
  874. tard » : il revient, indéfiniment. « 410 » signifie « c'était
  875. là, c'est fini » : il retire l'adresse de son index et cesse
  876. de la demander.
  877. Le rapport d'incident relève 5 523 appels sur des offres
  878. mortes, re-demandées en boucle. C'est ce trafic-là que le
  879. 410 supprime — pas immédiatement, mais définitivement.
  880. Le second `getOneOrNullResult` coûte une requête de plus,
  881. et seulement dans le cas où l'on répondait déjà en erreur.
  882. ══════════════════════════════════════════════════════════════ */
  883. $aExiste = $this->entityManager->getRepository(Jobs::class)
  884. ->createQueryBuilder('j')
  885. ->select('j.id')
  886. ->where('j.slug = :slug')
  887. ->setParameter('slug', $slug)
  888. ->setMaxResults(1)
  889. ->getQuery()->getOneOrNullResult();
  890. return new JsonResponse(
  891. ['error' => $aExiste ? 'GONE' : 'NOT_FOUND'],
  892. $aExiste ? 410 : 404,
  893. );
  894. }
  895. $enterprise = $job->getEnterprise();
  896. if ($enterprise && !$enterprise->isOnline()) {
  897. /* L'entreprise s'est retirée : l'offre a bien existé, elle ne
  898. reviendra pas sous cette adresse. Même raisonnement. */
  899. return new JsonResponse(['error' => 'GONE'], 410);
  900. }
  901. return $this->cacheable($this->serializeJobDetails($job));
  902. }
  903. public function enterprises(int $start, int $limit): JsonResponse
  904. {
  905. [$start, $limit] = $this->clamp($start, $limit);
  906. $qb = $this->queryEnterprises();
  907. $total = $this->compter($qb, 'e');
  908. $enterprises = $qb->setFirstResult($start)->setMaxResults($limit)
  909. ->getQuery()->getResult();
  910. return $this->cacheable($this->serializeEnterpriseList($enterprises), $total);
  911. }
  912. public function searchEnterprises(Request $request, int $start, int $limit): JsonResponse
  913. {
  914. [$start, $limit] = $this->clamp($start, $limit);
  915. $qb = $this->applyEnterpriseFilters($this->queryEnterprises(), $this->extractFilters($request));
  916. $total = $this->compter($qb, 'e');
  917. $enterprises = $qb->setFirstResult($start)->setMaxResults($limit)
  918. ->getQuery()->getResult();
  919. return $this->cacheable($this->serializeEnterpriseList($enterprises), $total);
  920. }
  921. public function enterprise(int $id): JsonResponse
  922. {
  923. $enterprise = $this->entityManager->getRepository(Enterprises::class)->find($id);
  924. if (!$enterprise || !$enterprise->isOnline()) {
  925. return new JsonResponse(['error' => 'NOT_FOUND'], 404);
  926. }
  927. return $this->cacheable($this->serializeEnterpriseDetails($enterprise));
  928. }
  929. /**
  930. * Le détail d'une entreprise PAR SON SLUG.
  931. *
  932. * Le site public expose /company/{slug} : une URL lisible et stable,
  933. * meilleure pour le référencement qu'un identifiant. Mêmes règles que
  934. * `enterprise()` : hors ligne = 404 (on n'énumère rien). Slug non
  935. * garanti unique en base → on prend la plus récente en ligne.
  936. */
  937. public function enterpriseBySlug(string $slug): JsonResponse
  938. {
  939. $enterprise = $this->entityManager->getRepository(Enterprises::class)
  940. ->createQueryBuilder('e')
  941. ->where('e.slug = :slug')
  942. ->andWhere('e.online = :online')
  943. ->setParameter('slug', $slug)
  944. ->setParameter('online', true)
  945. ->orderBy('e.updatedAt', 'DESC')
  946. ->setMaxResults(1)
  947. ->getQuery()->getOneOrNullResult();
  948. if (!$enterprise) {
  949. return new JsonResponse(['error' => 'NOT_FOUND'], 404);
  950. }
  951. return $this->cacheable($this->serializeEnterpriseDetails($enterprise));
  952. }
  953. /**
  954. * Un PAQUET ALÉATOIRE d'entreprises — pour le mode swipe de l'annuaire.
  955. *
  956. * Même principe que `randomJobs` : on pioche dans un vivier plus large que
  957. * N, on mélange en PHP, on garde N. `exclude` retire les entreprises déjà
  958. * vues (ids séparés par des virgules).
  959. */
  960. public function randomEnterprises(Request $request, int $count): JsonResponse
  961. {
  962. $count = max(1, min($count, self::LIMIT_MAX));
  963. $vivier = min(self::LIMIT_MAX, max($count * 4, 40));
  964. $enterprises = $this->queryEnterprises()
  965. ->setMaxResults($vivier)
  966. ->getQuery()->getResult();
  967. $exclude = array_filter(array_map('trim', explode(',', (string) $request->query->get('exclude', ''))));
  968. if ($exclude) {
  969. $set = array_flip($exclude);
  970. $enterprises = array_values(array_filter($enterprises, static function ($e) use ($set) {
  971. return !isset($set[(string) $e->getId()]);
  972. }));
  973. }
  974. shuffle($enterprises);
  975. $enterprises = array_slice($enterprises, 0, $count);
  976. return $this->cacheable($this->serializeEnterpriseList($enterprises));
  977. }
  978. // =========================================================================
  979. // FLUX MIXTE (page d'accueil)
  980. // =========================================================================
  981. public function mixed(int $start, int $limit): JsonResponse
  982. {
  983. [$start, $limit] = $this->clamp($start, $limit);
  984. $half = max(1, (int) ($limit / 2));
  985. $enterprises = $this->queryEnterprises()
  986. ->setFirstResult($start)->setMaxResults($half)
  987. ->getQuery()->getResult();
  988. $jobs = $this->queryJobs()
  989. ->setFirstResult($start)->setMaxResults($half)
  990. ->getQuery()->getResult();
  991. return $this->cacheable($this->mergeMixed($enterprises, $jobs));
  992. }
  993. public function searchMixed(Request $request, int $start, int $limit): JsonResponse
  994. {
  995. [$start, $limit] = $this->clamp($start, $limit);
  996. $filters = $this->extractFilters($request);
  997. $half = max(1, (int) ($limit / 2));
  998. $enterprises = $this->applyEnterpriseFilters($this->queryEnterprises(), $filters)
  999. ->setFirstResult($start)->setMaxResults($half)
  1000. ->getQuery()->getResult();
  1001. $jobs = $this->applyJobFilters($this->queryJobs(), $filters)
  1002. ->setFirstResult($start)->setMaxResults($half)
  1003. ->getQuery()->getResult();
  1004. return $this->cacheable($this->mergeMixed($enterprises, $jobs));
  1005. }
  1006. /** Le flux mixte : chaque objet porte son `type`, comme dans JobsController. */
  1007. private function mergeMixed(array $enterprises, array $jobs): array
  1008. {
  1009. $result = [];
  1010. foreach ($this->serializeEnterpriseList($enterprises) as $e) {
  1011. $e['type'] = 'enterprise';
  1012. $result[] = $e;
  1013. }
  1014. foreach ($this->serializeJobList($jobs) as $j) {
  1015. $j['type'] = 'job';
  1016. $result[] = $j;
  1017. }
  1018. return $result;
  1019. }
  1020. private function serializeJobList(array $jobs): array
  1021. {
  1022. /* ⚠️ UNE REQUÊTE POUR TOUTES LES DATES, PAS UNE PAR OFFRE.
  1023. `state()` dans la boucle, ce serait 13 requêtes de plus pour 13
  1024. lignes. Ce contrôleur existe justement parce que l'ancien en faisait
  1025. déjà trop. On ne va pas ajouter un N+1 en corrigeant un N+1. */
  1026. $this->etats = $this->expiry->states($jobs);
  1027. $result = [];
  1028. foreach ($jobs as $job) {
  1029. $result[] = $this->serializeJob($job);
  1030. }
  1031. return $result;
  1032. }
  1033. private function serializeEnterpriseList(array $enterprises): array
  1034. {
  1035. $result = [];
  1036. foreach ($enterprises as $enterprise) {
  1037. $jobsCount = $this->entityManager->getRepository(Jobs::class)
  1038. ->createQueryBuilder('j')
  1039. ->select('COUNT(j.id)')
  1040. ->where('j.enterprise = :ent')
  1041. ->andWhere('j.online = :on')
  1042. ->andWhere('j.verification = :ver')
  1043. ->setParameter('ent', $enterprise)
  1044. ->setParameter('on', true)
  1045. ->setParameter('ver', true)
  1046. ->getQuery()->getSingleScalarResult();
  1047. $data = $this->serializeEnterprise($enterprise);
  1048. $data['jobOffersCount'] = (int) $jobsCount;
  1049. $result[] = $data;
  1050. }
  1051. return $result;
  1052. }
  1053. private function serializeJob($job): array
  1054. {
  1055. $projectDir = $this->getParameter('kernel.project_dir');
  1056. $enterprise = $job->getEnterprise();
  1057. $skills = $job->getRequiredSkills();
  1058. if (is_string($skills)) { $d = json_decode($skills, true); $skills = is_array($d) ? $d : []; }
  1059. $nice = $job->getNiceToHaveSkills();
  1060. if (is_string($nice)) { $d = json_decode($nice, true); $nice = is_array($d) ? $d : []; }
  1061. return [
  1062. 'id' => (string) $job->getId(),
  1063. 'title' => $job->getJobTitle(),
  1064. 'shortTitle' => $job->getShortTitle(),
  1065. 'shortDescription' => $job->getShortDescription(),
  1066. 'category' => $job->getCategory(),
  1067. 'employmentType' => $job->getEmploymentType(),
  1068. 'experienceLevel' => $job->getExperienceLevel(),
  1069. 'remoteWork' => $job->getRemoteWork(),
  1070. 'city' => $job->getCity(),
  1071. 'country' => $job->getCountry(),
  1072. /* ⚠️ LE RATTACHEMENT AU RÉFÉRENTIEL, TRANSMIS TEL QUEL.
  1073. `cityRef` (VIL-…) est copié depuis le backoffice du site
  1074. vitrine — c'est LÀ que vivent le nom canonique de la ville
  1075. et son pays, traduits en huit langues. L'API ne les résout
  1076. pas : elle n'a pas les libellés par langue, et n'a pas à
  1077. les avoir. Le site les affiche EN PRIORITÉ et retombe sur
  1078. `city`/`country` ci-dessus quand l'offre n'est pas
  1079. rattachée. `cityRaw` garde la saisie du recruteur, qui
  1080. porte parfois une précision qu'aucun slug ne rend —
  1081. « Remote/Hybrid (US-based) ». */
  1082. 'tradeRef' => $job->getTradeRef(),
  1083. 'cityRef' => $job->getCityRef(),
  1084. 'citySlug' => $job->getCitySlug(),
  1085. 'areaCode' => $job->getAreaCode(),
  1086. 'cityRaw' => $job->getCity(),
  1087. 'salaryMin' => $job->getSalaryMin(),
  1088. 'salaryMax' => $job->getSalaryMax(),
  1089. 'devise' => $job->getDevise(),
  1090. 'salaryPeriod' => $job->getSalaryPeriod(),
  1091. 'skills' => $skills,
  1092. 'niceToHaveSkills' => $nice,
  1093. 'createdAt' => $job->getCreatedAt()->format('c'),
  1094. 'updatedAt' => $job->getUpdatedAt()->format('c'),
  1095. /* ⚠️ LE CYCLE DE VIE. C'EST CE QUE GOOGLE LIT.
  1096. `publishedAt` → datePosted
  1097. `expiresAt` → validThrough ← sans lui, Google déclasse l'offre
  1098. `estimated: true` signale une offre d'AVANT la table de
  1099. publication : la date est calculée depuis `createdAt`. Le site
  1100. doit nuancer son message plutôt qu'affirmer une date qu'on n'a
  1101. jamais enregistrée. */
  1102. 'publishedAt' => $this->etat($job)['publishedAt'],
  1103. 'expiresAt' => $this->etat($job)['expiresAt'],
  1104. 'expired' => $this->etat($job)['expired'],
  1105. 'daysLeft' => $this->etat($job)['daysLeft'],
  1106. 'closedReason' => $this->etat($job)['closedReason'],
  1107. 'estimated' => $this->etat($job)['estimated'],
  1108. // Anonyme : rien n'est liké. Aucune requête.
  1109. 'liked' => false,
  1110. 'websearch' => (bool) $job->isWebsearch(),
  1111. 'website' => $job->getWebsite(),
  1112. 'contactURL' => $job->getContactURL(),
  1113. 'slug' => $job->getSlug(),
  1114. 'enterprise' => $enterprise ? [
  1115. 'id' => (string) $enterprise->getId(),
  1116. 'slug' => $enterprise->getSlug(),
  1117. 'locale' => $enterprise->getLocale(),
  1118. 'name' => $enterprise->getCompanyName(),
  1119. 'logo' => $enterprise->getImageBase64($projectDir),
  1120. 'city' => $enterprise->getCity(),
  1121. 'country' => $enterprise->getCountry(),
  1122. ] : null,
  1123. ];
  1124. }
  1125. private function serializeJobDetails($job): array
  1126. {
  1127. $projectDir = $this->getParameter('kernel.project_dir');
  1128. $enterprise = $job->getEnterprise();
  1129. $skills = $job->getRequiredSkills();
  1130. if (is_string($skills)) { $d = json_decode($skills, true); $skills = is_array($d) ? $d : []; }
  1131. $nice = $job->getNiceToHaveSkills();
  1132. if (is_string($nice)) { $d = json_decode($nice, true); $nice = is_array($d) ? $d : []; }
  1133. return [
  1134. 'id' => (string) $job->getId(),
  1135. 'title' => $job->getJobTitle(),
  1136. 'description' => $job->getJobSummary(),
  1137. 'responsibilities' => $job->getKeyResponsabilities(),
  1138. 'requirements' => $job->getRequirements(),
  1139. 'skills' => $skills,
  1140. 'niceToHaveSkills' => $nice,
  1141. 'benefits' => $job->getBenefits(),
  1142. 'category' => $job->getCategory(),
  1143. 'employmentType' => $job->getEmploymentType(),
  1144. 'experienceLevel' => $job->getExperienceLevel(),
  1145. 'remoteWork' => $job->getRemoteWork(),
  1146. 'city' => $job->getCity(),
  1147. 'country' => $job->getCountry(),
  1148. /* Le rattachement au référentiel — mêmes quatre champs que la
  1149. liste (voir serializeJob). La fiche est LA page où le lieu
  1150. canonique compte : c'est elle qui porte le JSON-LD. */
  1151. 'tradeRef' => $job->getTradeRef(),
  1152. 'cityRef' => $job->getCityRef(),
  1153. 'citySlug' => $job->getCitySlug(),
  1154. 'areaCode' => $job->getAreaCode(),
  1155. 'cityRaw' => $job->getCity(),
  1156. 'salaryMin' => $job->getSalaryMin(),
  1157. 'salaryMax' => $job->getSalaryMax(),
  1158. 'devise' => $job->getDevise(),
  1159. 'salaryPeriod' => $job->getSalaryPeriod(),
  1160. 'createdAt' => $job->getCreatedAt()->format('c'),
  1161. 'updatedAt' => $job->getUpdatedAt()->format('c'),
  1162. 'publishedAt' => $this->etat($job)['publishedAt'],
  1163. 'expiresAt' => $this->etat($job)['expiresAt'],
  1164. 'expired' => $this->etat($job)['expired'],
  1165. 'daysLeft' => $this->etat($job)['daysLeft'],
  1166. 'closedReason' => $this->etat($job)['closedReason'],
  1167. 'estimated' => $this->etat($job)['estimated'],
  1168. 'liked' => false,
  1169. 'websearch' => (bool) $job->isWebsearch(),
  1170. 'website' => $job->getWebsite(),
  1171. 'contactURL' => $job->getContactURL(),
  1172. 'slug' => $job->getSlug(),
  1173. 'enterprise' => $enterprise ? [
  1174. 'id' => (string) $enterprise->getId(),
  1175. 'slug' => $enterprise->getSlug(),
  1176. 'locale' => $enterprise->getLocale(),
  1177. 'name' => $enterprise->getCompanyName(),
  1178. 'logo' => $enterprise->getImageBase64($projectDir),
  1179. 'category' => $enterprise->getCategory(),
  1180. 'website' => $enterprise->getWebsite(),
  1181. 'city' => $enterprise->getCity(),
  1182. 'country' => $enterprise->getCountry(),
  1183. ] : null,
  1184. ];
  1185. }
  1186. private function serializeEnterprise($enterprise): array
  1187. {
  1188. $projectDir = $this->getParameter('kernel.project_dir');
  1189. return [
  1190. 'id' => (string) $enterprise->getId(),
  1191. 'name' => $enterprise->getCompanyName(),
  1192. 'category' => $enterprise->getCategory(),
  1193. 'shortDescription' => $enterprise->getShortDescription(),
  1194. 'logo' => $enterprise->getImageBase64($projectDir),
  1195. 'website' => $enterprise->getWebsite(),
  1196. 'city' => $enterprise->getCity(),
  1197. 'country' => $enterprise->getCountry(),
  1198. /* ══════════════════════════════════════════════════════════
  1199. ⚠️ UN BOOLÉEN, PAS LE COMPTE.
  1200. Le site doit savoir si quelqu'un LIT les candidatures avant
  1201. de proposer d'envoyer un CV : une fiche importée sans compte
  1202. rattaché n'a personne au bout, et le candidat attend une
  1203. réponse qui ne viendra jamais.
  1204. On rend donc `true`/`false` — jamais l'identifiant ni
  1205. l'e-mail du recruteur. Exposer le compte sur un endpoint
  1206. public, c'est publier une donnée personnelle pour répondre à
  1207. une question qui appelle un oui ou un non.
  1208. ══════════════════════════════════════════════════════════ */
  1209. 'hasAccount' => $enterprise->getUser() !== null,
  1210. /* Le rattachement au référentiel — mêmes quatre champs que les
  1211. offres (voir serializeJob) : le site résout les noms depuis
  1212. SON référentiel, en priorité sur la saisie. */
  1213. 'tradeRef' => $enterprise->getTradeRef(),
  1214. 'cityRef' => $enterprise->getCityRef(),
  1215. 'citySlug' => $enterprise->getCitySlug(),
  1216. 'areaCode' => $enterprise->getAreaCode(),
  1217. 'cityRaw' => $enterprise->getCity(),
  1218. 'slug' => $enterprise->getSlug(),
  1219. 'createdAt' => $enterprise->getCreatedAt()->format('c'),
  1220. 'updatedAt' => $enterprise->getUpdatedAt()->format('c'),
  1221. 'liked' => false,
  1222. ];
  1223. }
  1224. private function serializeEnterpriseDetails($enterprise): array
  1225. {
  1226. $projectDir = $this->getParameter('kernel.project_dir');
  1227. $jobs = $this->entityManager->getRepository(Jobs::class)
  1228. ->createQueryBuilder('j')
  1229. ->where('j.enterprise = :ent')
  1230. ->andWhere('j.online = :on')
  1231. ->setParameter('ent', $enterprise)
  1232. ->setParameter('on', true)
  1233. ->orderBy('j.updatedAt', 'DESC')
  1234. ->getQuery()->getResult();
  1235. $jobsData = $this->serializeJobList($jobs);
  1236. return [
  1237. 'id' => (string) $enterprise->getId(),
  1238. 'name' => $enterprise->getCompanyName(),
  1239. 'category' => $enterprise->getCategory(),
  1240. 'shortTitle' => $enterprise->getShortTitle(),
  1241. 'shortDescription' => $enterprise->getShortDescription(),
  1242. 'description' => $enterprise->getCompanyDescription(),
  1243. 'logo' => $enterprise->getImageBase64($projectDir),
  1244. 'website' => $enterprise->getWebsite(),
  1245. 'email' => $enterprise->getEmail(),
  1246. 'phone' => $enterprise->getPhone(),
  1247. 'address' => $enterprise->getAddress(),
  1248. 'city' => $enterprise->getCity(),
  1249. 'zipcode' => $enterprise->getZipcode(),
  1250. 'country' => $enterprise->getCountry(),
  1251. /* Un compte est-il rattaché ? Voir `serializeEnterprise` :
  1252. un booléen, jamais le compte lui-même. */
  1253. 'hasAccount' => $enterprise->getUser() !== null,
  1254. /* Le rattachement au référentiel — la fiche entreprise affiche
  1255. le lieu canonique en priorité, comme la fiche offre. */
  1256. 'tradeRef' => $enterprise->getTradeRef(),
  1257. 'cityRef' => $enterprise->getCityRef(),
  1258. 'citySlug' => $enterprise->getCitySlug(),
  1259. 'areaCode' => $enterprise->getAreaCode(),
  1260. 'cityRaw' => $enterprise->getCity(),
  1261. 'slug' => $enterprise->getSlug(),
  1262. 'contactName' => $enterprise->getContactName(),
  1263. 'contactEmail' => $enterprise->getContactEmail(),
  1264. 'contactURL' => $enterprise->getContactURL(),
  1265. 'createdAt' => $enterprise->getCreatedAt()->format('c'),
  1266. 'updatedAt' => $enterprise->getUpdatedAt()->format('c'),
  1267. 'liked' => false,
  1268. 'jobs' => $jobsData,
  1269. 'jobOffersCount' => count($jobsData),
  1270. ];
  1271. }
  1272. }