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

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