src/Controller/ThemesWebsite/Whileresume/Application/JobsController.php line 258

Open in your IDE?
  1. <?php
  2. namespace App\Controller\ThemesWebsite\Whileresume\Application;
  3. use App\Entity\Core\Users;
  4. use App\Entity\Cvs\Candidates;
  5. use App\Entity\Cvs\Jobs;
  6. use App\Entity\Cvs\JobsHasLikes;
  7. use App\Form\Core\UsersType;
  8. use App\Form\Cvs\JobsForm;
  9. use App\Form\Cvs\JobsFrForm;
  10. use App\Services\Core\RequestData;
  11. use Sensio\Bundle\FrameworkExtraBundle\Configuration\Cache;
  12. use Sensio\Bundle\FrameworkExtraBundle\Configuration\IsGranted;
  13. use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;
  14. use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
  15. use Symfony\Component\EventDispatcher\EventDispatcherInterface;
  16. use Symfony\Component\HttpFoundation\Request;
  17. use Symfony\Component\HttpFoundation\Response;
  18. use Symfony\Component\Routing\Annotation\Route;
  19. use Symfony\Component\HttpFoundation\Cookie;
  20. use Symfony\Component\HttpFoundation\JsonResponse;
  21. use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
  22. use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
  23. use Doctrine\ORM\EntityManagerInterface;
  24. class JobsController extends AbstractController
  25. {
  26. private $rd;
  27. private $em;
  28. public function __construct(RequestData $rd,
  29. EntityManagerInterface $em
  30. ) {
  31. $this->rd = $rd;
  32. $this->em = $em;
  33. }
  34. public function new(Request $request): Response
  35. {
  36. $locale = $request->getLocale();
  37. $user = $this->getUser();
  38. $jobClass = JobsForm::class;
  39. if($locale == "fr") {
  40. $jobClass = JobsFrForm::class;
  41. }
  42. $job = new Jobs();
  43. $form = $this->createForm($jobClass, $job);
  44. $form->handleRequest($request);
  45. if ($form->isSubmitted() && $form->isValid()) {
  46. $job->setSlug("0");
  47. $job->setLocale($locale);
  48. $job->setValidation(false);
  49. $job->setVerification(false);
  50. $job->setOnline(false);
  51. $job->setEnterprise(null);
  52. $job->setUser($user);
  53. $this->em->persist($job);
  54. $this->em->flush();
  55. if($job->getSlug() != "0") {
  56. $job->setSlug($this->generateSlug($job->getId()));
  57. $this->em->persist($job);
  58. $this->em->flush();
  59. }
  60. if($locale !== "en") {
  61. return $this->redirectToRoute('locale_whileresume_jobs_list',['_locale' => $locale]);
  62. }
  63. return $this->redirectToRoute('whileresume_jobs_list');
  64. }
  65. return $this->render('application/whileresume/application/jobs/new_'.$locale.'.html.twig',[
  66. 'form' => $form->createView(),
  67. ]);
  68. }
  69. private function generateSlug(int $id): string
  70. {
  71. $letters = 'abcdefghijklmnopqrstuvwxyz';
  72. $random = '';
  73. for ($i = 0; $i < 6; $i++) {
  74. $random .= $letters[random_int(0, 25)];
  75. }
  76. return $random . '-' . $id;
  77. }
  78. public function show(Request $request, $slug): Response
  79. {
  80. $locale = $request->getLocale();
  81. $jobsRepo = $this->em->getRepository(Jobs::class);
  82. $job = $jobsRepo->findOneBy(['locale' => $locale, 'slug' => $slug, 'online' => true, 'validation' => true]);
  83. if($job === null) {
  84. throw $this->createNotFoundException('Page non trouvée');
  85. }
  86. $othersJobs = null;
  87. if($job->getEnterprise() != null) {
  88. $othersJobs = $jobsRepo->findBy(['enterprise' => $job->getEnterprise(), 'online' => true]);
  89. }
  90. // Offres similaires basées sur catégorie, compétences, ville, titre
  91. $similarJobs = $jobsRepo->findSimilarJobs($job, 5);
  92. // Filtres sidebar : tags polymorphes (villes + métiers) configurés en BDD
  93. $filtersRepo = $this->em->getRepository(\App\Entity\Cvs\JobsFilters::class);
  94. $sidebarFilters = $filtersRepo->findActiveByLocale($locale, 30);
  95. // Séparer par type pour l'affichage (city vs category)
  96. $sidebarFiltersCities = [];
  97. $sidebarFiltersCategories = [];
  98. foreach ($sidebarFilters as $f) {
  99. if ($f->getType() === 'city') {
  100. $sidebarFiltersCities[] = $f;
  101. } else {
  102. $sidebarFiltersCategories[] = $f;
  103. }
  104. }
  105. // Keywords cliquables sous la barre de recherche (homepage de la locale)
  106. $keywordsRepo = $this->em->getRepository(\App\Entity\Cvs\KeywordsLandingJobs::class);
  107. $searchKeywords = $keywordsRepo->findByLocaleForHomepage($locale);
  108. $count = 0;
  109. if($job->getViews() != null) {
  110. $count = $job->getViews() + 1;
  111. }
  112. $job->setViews($count);
  113. $this->em->persist($job);
  114. $this->em->flush();
  115. // État du like pour le candidat connecté (rendu initial du bouton)
  116. $isLiked = false;
  117. $currentUser = $this->getUser();
  118. if ($currentUser !== null && $currentUser->getCandidate() !== null) {
  119. $existingLike = $this->em->getRepository(JobsHasLikes::class)
  120. ->findOneBy(['candidate' => $currentUser->getCandidate(), 'job' => $job]);
  121. $isLiked = $existingLike !== null;
  122. }
  123. return $this->render('application/whileresume/application/jobs/show.html.twig',[
  124. 'slug' => $slug,
  125. 'job' => $job,
  126. 'othersJobs' => $othersJobs,
  127. 'similarJobs' => $similarJobs,
  128. 'sidebarFiltersCities' => $sidebarFiltersCities,
  129. 'sidebarFiltersCategories' => $sidebarFiltersCategories,
  130. 'searchKeywords' => $searchKeywords,
  131. 'isLiked' => $isLiked,
  132. ]);
  133. }
  134. /**
  135. * Retourne les détails d'une offre en JSON pour la fiche modale (AJAX).
  136. * Si l'utilisateur n'est pas connecté, retourne uniquement les champs publics
  137. * (résumé + meta) avec un flag "locked" sur les détails.
  138. */
  139. public function ajaxShow(Request $request, $slug): JsonResponse
  140. {
  141. $locale = $request->getLocale();
  142. $user = $this->getUser();
  143. $job = $this->em->getRepository(Jobs::class)->findOneBy([
  144. 'locale' => $locale,
  145. 'slug' => $slug,
  146. 'online' => true,
  147. 'validation' => true
  148. ]);
  149. if ($job === null) {
  150. return new JsonResponse(['error' => 'not_found'], 404);
  151. }
  152. $isAuthenticated = $user !== null;
  153. $isCandidate = $isAuthenticated && $user->getCandidate() !== null;
  154. $enterpriseData = null;
  155. if ($job->getEnterprise() !== null) {
  156. $enterpriseData = [
  157. 'id' => $job->getEnterprise()->getId(),
  158. 'slug' => $job->getEnterprise()->getSlug(),
  159. 'name' => $job->getEnterprise()->getCompanyName(),
  160. 'logo' => method_exists($job->getEnterprise(), 'getLogo') ? $job->getEnterprise()->getLogo() : null,
  161. 'city' => method_exists($job->getEnterprise(), 'getCity') ? $job->getEnterprise()->getCity() : null,
  162. 'country' => method_exists($job->getEnterprise(), 'getCountry') ? $job->getEnterprise()->getCountry() : null,
  163. ];
  164. }
  165. // Données publiques (toujours visibles)
  166. $payload = [
  167. 'id' => $job->getId(),
  168. 'slug' => $job->getSlug(),
  169. 'title' => $job->getJobTitle(),
  170. 'companyName' => $job->getCompanyName(),
  171. 'category' => $job->getCategory(),
  172. 'city' => $job->getCity(),
  173. 'country' => $job->getCountry(),
  174. 'employmentType' => $job->getEmploymentType(),
  175. 'remoteWork' => $job->getRemoteWork(),
  176. 'experienceLevel' => $job->getExperienceLevel(),
  177. 'salaryMin' => $job->getSalaryMin(),
  178. 'salaryMax' => $job->getSalaryMax(),
  179. 'salaryPeriod' => $job->getSalaryPeriod(),
  180. 'devise' => $job->getDevise(),
  181. 'jobSummary' => $job->getJobSummary(),
  182. 'verification' => $job->isVerification(),
  183. 'website' => method_exists($job, 'getWebsite') ? $job->getWebsite() : null,
  184. 'websearch' => method_exists($job, 'getWebsearch') ? $job->getWebsearch() : null,
  185. 'enterprise' => $enterpriseData,
  186. 'isAuthenticated' => $isAuthenticated,
  187. 'isCandidate' => $isCandidate,
  188. 'locked' => !$isAuthenticated,
  189. 'showUrl' => $locale === 'en'
  190. ? $this->generateUrl('cvs_application_job_show', ['slug' => $slug])
  191. : $this->generateUrl('locale_cvs_application_job_show', ['_locale' => $locale, 'slug' => $slug]),
  192. 'likeUrl' => null,
  193. ];
  194. // Données détaillées (uniquement si connecté)
  195. if ($isAuthenticated) {
  196. $payload['keyResponsabilities'] = $job->getKeyResponsabilities();
  197. $payload['requirements'] = $job->getRequirements();
  198. $payload['benefits'] = $job->getBenefits();
  199. if ($isCandidate) {
  200. $payload['likeUrl'] = $locale === 'en'
  201. ? $this->generateUrl('cvs_application_job_like', ['slug' => $slug])
  202. : $this->generateUrl('locale_cvs_application_job_like', ['_locale' => $locale, 'slug' => $slug]);
  203. // État du like pour ce candidat (utilisé par buildJobHtml côté JS swipe)
  204. $existingLike = $this->em->getRepository(JobsHasLikes::class)
  205. ->findOneBy(['candidate' => $user->getCandidate(), 'job' => $job]);
  206. $payload['isLiked'] = $existingLike !== null;
  207. } else {
  208. $payload['isLiked'] = false;
  209. }
  210. } else {
  211. $payload['isLiked'] = false;
  212. }
  213. return new JsonResponse($payload);
  214. }
  215. /**
  216. * Page dashboard / liste publique des offres.
  217. * URL : /{locale}/jobs
  218. *
  219. * Affiche la sidebar Sociala (filtres villes/métiers + chips keywords)
  220. * + une liste des offres alimentée en AJAX via apiSearch (avec "Charger plus").
  221. */
  222. public function dashboard(Request $request): Response
  223. {
  224. $locale = $request->getLocale();
  225. // Accepte ?q= (legacy) ou ?search= (nouveau, pour les fallbacks depuis les pages ville)
  226. $initialQuery = trim((string) $request->query->get('search', $request->query->get('q', '')));
  227. // Filtres sidebar (villes / métiers)
  228. $filtersRepo = $this->em->getRepository(\App\Entity\Cvs\JobsFilters::class);
  229. $sidebarFilters = $filtersRepo->findActiveByLocale($locale, 30);
  230. $sidebarFiltersCities = [];
  231. $sidebarFiltersCategories = [];
  232. foreach ($sidebarFilters as $f) {
  233. if ($f->getType() === 'city') {
  234. $sidebarFiltersCities[] = $f;
  235. } else {
  236. $sidebarFiltersCategories[] = $f;
  237. }
  238. }
  239. // Keywords cliquables sous la barre de recherche
  240. $keywordsRepo = $this->em->getRepository(\App\Entity\Cvs\KeywordsLandingJobs::class);
  241. $searchKeywords = $keywordsRepo->findByLocaleForHomepage($locale);
  242. return $this->render('application/whileresume/application/jobs/dashboard.html.twig', [
  243. 'initialQuery' => $initialQuery,
  244. 'sidebarFiltersCities' => $sidebarFiltersCities,
  245. 'sidebarFiltersCategories' => $sidebarFiltersCategories,
  246. 'searchKeywords' => $searchKeywords,
  247. 'currentCityFilter' => null,
  248. 'currentCityKeyword' => '',
  249. 'currentSubKeyword' => null,
  250. 'currentSubKeywordSlug' => null,
  251. ]);
  252. }
  253. /**
  254. * Page landing pour un filtre (ville/métier/etc.) configuré dans cvs_jobsfilters.
  255. * URL : /{locale}/jobs/{slug} → ex: /fr/jobs/paris ou /fr/jobs/developpement-web
  256. *
  257. * Rend le même template que le dashboard, avec :
  258. * - le filtre actif marqué (chip violet plein)
  259. * - le searchKeyword pré-rempli dans la barre de recherche
  260. * - la liste pré-filtrée sur ce keyword
  261. */
  262. public function filterLanding(Request $request, $slug): Response
  263. {
  264. $locale = $request->getLocale();
  265. $filtersRepo = $this->em->getRepository(\App\Entity\Cvs\JobsFilters::class);
  266. $filter = $filtersRepo->findOneBySlugAndLocale($slug, $locale);
  267. if ($filter === null) {
  268. throw $this->createNotFoundException('Filtre introuvable');
  269. }
  270. // Le keyword utilisé pour la recherche (pré-rempli dans la barre)
  271. $keyword = $filter->getSearchKeyword() ?: $filter->getLabel();
  272. // Filtres sidebar
  273. $sidebarFilters = $filtersRepo->findActiveByLocale($locale, 30);
  274. $sidebarFiltersCities = [];
  275. $sidebarFiltersCategories = [];
  276. foreach ($sidebarFilters as $f) {
  277. if ($f->getType() === 'city') {
  278. $sidebarFiltersCities[] = $f;
  279. } else {
  280. $sidebarFiltersCategories[] = $f;
  281. }
  282. }
  283. // Keywords cliquables sous la barre de recherche
  284. $keywordsRepo = $this->em->getRepository(\App\Entity\Cvs\KeywordsLandingJobs::class);
  285. $searchKeywords = $keywordsRepo->findByLocaleForHomepage($locale);
  286. // Si le filtre est une VILLE → on contraint les recherches à cette ville
  287. // Sinon (catégorie/métier) → c'est juste un préfiltre keyword classique
  288. $isCityContext = ($filter->getType() === 'city');
  289. // Sur une page ville, on accepte aussi ?search=texte (recherche libre dans la ville)
  290. $freeSearch = $isCityContext ? trim((string) $request->query->get('search', '')) : '';
  291. $initialQuery = $isCityContext
  292. ? $freeSearch // search libre prioritaire sinon vide
  293. : $keyword; // catégorie/métier → préfiltre par défaut
  294. // Logger la visite de cette page landing
  295. try {
  296. $statsRepo = $this->em->getRepository(\App\Entity\Cvs\SearchStats::class);
  297. if ($isCityContext) {
  298. // Page ville : on log la ville (+ recherche libre si fournie)
  299. $statsRepo->logSearch($freeSearch, $keyword, $locale, 'landing_city');
  300. } else {
  301. // Page catégorie/métier : on log le keyword comme query
  302. $statsRepo->logSearch($keyword, null, $locale, 'landing_keyword');
  303. }
  304. } catch (\Throwable $e) { /* silent fail */ }
  305. return $this->render('application/whileresume/application/jobs/dashboard.html.twig', [
  306. 'initialQuery' => $initialQuery,
  307. 'sidebarFiltersCities' => $sidebarFiltersCities,
  308. 'sidebarFiltersCategories' => $sidebarFiltersCategories,
  309. 'searchKeywords' => $searchKeywords,
  310. 'currentFilter' => $filter, // pour SEO + chip active
  311. 'currentCityFilter' => $isCityContext ? $filter : null, // contrainte ville pour la recherche
  312. 'currentCityKeyword' => $isCityContext ? $keyword : '', // keyword utilisé pour matcher j.city
  313. 'currentSubKeyword' => null, // pas de sous-keyword sur cette page
  314. 'currentSubKeywordSlug' => null,
  315. ]);
  316. }
  317. /**
  318. * Page combinée ville + keyword : /jobs/{slug}/{keyword}
  319. * Ex: /fr/jobs/paris/developpeur → toutes les offres "développeur" À PARIS
  320. *
  321. * Le {slug} doit correspondre à un filtre de type 'city' actif. Sinon 404.
  322. * Le {keyword} est un slug libre, qui peut correspondre à un KeywordsLandingJobs ou pas.
  323. */
  324. public function filterLandingKeyword(Request $request, $slug, $keyword): Response
  325. {
  326. $locale = $request->getLocale();
  327. $filtersRepo = $this->em->getRepository(\App\Entity\Cvs\JobsFilters::class);
  328. $filter = $filtersRepo->findOneBySlugAndLocale($slug, $locale);
  329. if ($filter === null || $filter->getType() !== 'city') {
  330. throw $this->createNotFoundException('Filtre ville introuvable');
  331. }
  332. // Recherche : on combine la ville (contrainte j.city) + le keyword (recherche full-text)
  333. $cityKeyword = $filter->getSearchKeyword() ?: $filter->getLabel();
  334. // Le keyword peut soit correspondre à un KeywordsLandingJobs (BDD), soit être libre
  335. $keywordsRepo = $this->em->getRepository(\App\Entity\Cvs\KeywordsLandingJobs::class);
  336. $searchKeywords = $keywordsRepo->findByLocaleForHomepage($locale);
  337. // Cherche dans les keywords config si le slug en URL matche
  338. $matchedKeyword = null;
  339. foreach ($searchKeywords as $kw) {
  340. if ($this->slugify($kw->getLabel()) === $keyword) {
  341. $matchedKeyword = $kw;
  342. break;
  343. }
  344. }
  345. // Si match trouvé : on prend le searchKeyword/label défini ; sinon : on utilise le slug brut comme query
  346. $subKeywordQuery = $matchedKeyword !== null
  347. ? ($matchedKeyword->getSearchKeyword() ?: $matchedKeyword->getLabel())
  348. : str_replace('-', ' ', $keyword);
  349. // Filtres sidebar
  350. $sidebarFilters = $filtersRepo->findActiveByLocale($locale, 30);
  351. $sidebarFiltersCities = [];
  352. $sidebarFiltersCategories = [];
  353. foreach ($sidebarFilters as $f) {
  354. if ($f->getType() === 'city') {
  355. $sidebarFiltersCities[] = $f;
  356. } else {
  357. $sidebarFiltersCategories[] = $f;
  358. }
  359. }
  360. // Logger la visite : query = keyword, ville = cityKeyword
  361. try {
  362. $statsRepo = $this->em->getRepository(\App\Entity\Cvs\SearchStats::class);
  363. $statsRepo->logSearch($subKeywordQuery, $cityKeyword, $locale, 'landing_keyword');
  364. } catch (\Throwable $e) { /* silent fail */ }
  365. return $this->render('application/whileresume/application/jobs/dashboard.html.twig', [
  366. 'initialQuery' => $subKeywordQuery,
  367. 'sidebarFiltersCities' => $sidebarFiltersCities,
  368. 'sidebarFiltersCategories' => $sidebarFiltersCategories,
  369. 'searchKeywords' => $searchKeywords,
  370. 'currentFilter' => $filter,
  371. 'currentCityFilter' => $filter,
  372. 'currentCityKeyword' => $cityKeyword,
  373. 'currentSubKeyword' => $matchedKeyword !== null ? $matchedKeyword->getLabel() : ucfirst(str_replace('-', ' ', $keyword)),
  374. 'currentSubKeywordSlug' => $keyword,
  375. ]);
  376. }
  377. /**
  378. * Slugifie une chaîne (ex: "Développeur Web" → "developpeur-web")
  379. * Utilisé pour matcher les keywords URL avec ceux de la BDD.
  380. * IMPORTANT : doit produire EXACTEMENT le même slug que le filtre Twig
  381. * utilisé dans dashboard.html.twig pour générer les liens des chips.
  382. */
  383. private function slugify(string $text): string
  384. {
  385. // Pré-remplacement des caractères spéciaux SÉMANTIQUES (avant la translitération)
  386. // pour rester cohérent avec le Twig qui traite '&' → '-and-', '+' → '-plus-',
  387. // 'œ' → 'oe', 'æ' → 'ae' (Latin-ASCII les remplace souvent par '' tout court)
  388. $text = strtr($text, [
  389. '&' => '-and-',
  390. '+' => '-plus-',
  391. 'œ' => 'oe',
  392. 'Œ' => 'oe',
  393. 'æ' => 'ae',
  394. 'Æ' => 'ae',
  395. ]);
  396. // Translitération des accents → ASCII + lowercase
  397. if (function_exists('transliterator_transliterate')) {
  398. $text = transliterator_transliterate('Any-Latin; Latin-ASCII; Lower()', $text);
  399. } else {
  400. // Fallback : iconv
  401. $text = strtolower(iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $text));
  402. }
  403. // Remplace tout ce qui n'est pas alphanumérique par un tiret
  404. $text = preg_replace('/[^a-z0-9]+/', '-', $text);
  405. // Trim les tirets en début/fin
  406. return trim($text, '-');
  407. }
  408. /**
  409. * API publique de recherche d'offres pour l'autocomplétion.
  410. * GET /api/jobs/search?q=...&locale=...&limit=...&city=...
  411. */
  412. public function apiSearch(Request $request): JsonResponse
  413. {
  414. $query = trim((string) $request->query->get('q', ''));
  415. $locale = $request->query->get('locale', $request->getLocale());
  416. $limit = max(1, min(20, (int) $request->query->get('limit', 6)));
  417. $offset = max(0, (int) $request->query->get('offset', 0));
  418. $city = trim((string) $request->query->get('city', ''));
  419. $cityKeyword = $city !== '' ? $city : null;
  420. // Mode "tout afficher" : utilisé par la page dashboard pour lister
  421. // les dernières offres quand aucun filtre n'est appliqué.
  422. // (Les widgets d'autocomplétion ne passent PAS ce flag : ils gardent
  423. // l'ancien comportement "pas de spam tant que la query fait < 2 chars".)
  424. $allowEmpty = filter_var($request->query->get('all', '0'), FILTER_VALIDATE_BOOLEAN);
  425. // Si on a une ville mais pas de query, on autorise (la ville suffit comme filtre)
  426. // Si on demande explicitement "toutes les offres" (dashboard), on autorise aussi
  427. if (mb_strlen($query) < 2 && $cityKeyword === null && !$allowEmpty) {
  428. return new JsonResponse([
  429. 'items' => [],
  430. 'total' => 0,
  431. 'query' => $query,
  432. ]);
  433. }
  434. $repo = $this->em->getRepository(Jobs::class);
  435. $jobs = $repo->searchJobs($query, $locale, $offset, $limit, $cityKeyword);
  436. $total = $repo->countSearchJobs($query, $locale, $cityKeyword);
  437. // Logger la recherche (uniquement au 1er chargement, pas en pagination)
  438. if ($offset === 0) {
  439. try {
  440. $statsRepo = $this->em->getRepository(\App\Entity\Cvs\SearchStats::class);
  441. $statsRepo->logSearch($query, $cityKeyword, $locale, 'submit');
  442. } catch (\Throwable $e) { /* silent fail */ }
  443. }
  444. $items = [];
  445. foreach ($jobs as $job) {
  446. $logo = null;
  447. if ($job->getEnterprise() !== null && method_exists($job->getEnterprise(), 'getLogo')) {
  448. $logo = $job->getEnterprise()->getLogo();
  449. }
  450. $items[] = [
  451. 'id' => $job->getId(),
  452. 'slug' => $job->getSlug(),
  453. 'title' => $job->getJobTitle(),
  454. 'companyName' => $job->getCompanyName(),
  455. 'category' => $job->getCategory(),
  456. 'city' => $job->getCity(),
  457. 'country' => $job->getCountry(),
  458. 'employmentType' => $job->getEmploymentType(),
  459. 'salaryMin' => $job->getSalaryMin(),
  460. 'salaryMax' => $job->getSalaryMax(),
  461. 'devise' => $job->getDevise(),
  462. 'logo' => $logo,
  463. 'url' => $locale === 'en'
  464. ? $this->generateUrl('cvs_application_job_show', ['slug' => $job->getSlug()])
  465. : $this->generateUrl('locale_cvs_application_job_show', ['_locale' => $locale, 'slug' => $job->getSlug()]),
  466. ];
  467. }
  468. return new JsonResponse([
  469. 'items' => $items,
  470. 'total' => $total,
  471. 'query' => $query,
  472. 'city' => $cityKeyword,
  473. 'limit' => $limit,
  474. 'offset' => $offset,
  475. ]);
  476. }
  477. /**
  478. * Like / unlike (toggle) d'une offre par le candidat connecté.
  479. *
  480. * - Requête AJAX (X-Requested-With: XMLHttpRequest ou Accept: application/json)
  481. * → retourne JSON {success, liked, jobId, slug}
  482. * - Requête classique (GET direct depuis un <a>)
  483. * → comportement legacy : redirection vers le dashboard candidat
  484. *
  485. * Cas d'erreur AJAX :
  486. * - 401 unauthenticated : utilisateur non connecté
  487. * - 403 no_candidate_profile: connecté mais sans profil candidat
  488. * - 404 not_found : offre introuvable
  489. */
  490. public function likeJob(Request $request, $slug): Response
  491. {
  492. $locale = $request->getLocale();
  493. $user = $this->getUser();
  494. $isAjax = $request->isXmlHttpRequest()
  495. || str_contains((string) $request->headers->get('Accept'), 'application/json');
  496. // Non connecté
  497. if ($user === null) {
  498. if ($isAjax) {
  499. return new JsonResponse([
  500. 'success' => false,
  501. 'error' => 'unauthenticated',
  502. 'loginUrl' => $locale === 'en'
  503. ? $this->generateUrl('app_login')
  504. : $this->generateUrl('locale_app_login', ['_locale' => $locale]),
  505. ], 401);
  506. }
  507. return $locale === 'en'
  508. ? $this->redirectToRoute('cvs_gestion_candidates_dashboard')
  509. : $this->redirectToRoute('locale_cvs_gestion_candidates_dashboard', ['_locale' => $locale]);
  510. }
  511. $candidate = $user->getCandidate();
  512. $job = $this->em->getRepository(Jobs::class)->findOneBy(['slug' => $slug]);
  513. if ($job === null) {
  514. if ($isAjax) {
  515. return new JsonResponse(['success' => false, 'error' => 'not_found'], 404);
  516. }
  517. throw $this->createNotFoundException('Offre introuvable');
  518. }
  519. if ($candidate === null) {
  520. if ($isAjax) {
  521. return new JsonResponse(['success' => false, 'error' => 'no_candidate_profile'], 403);
  522. }
  523. return $locale === 'en'
  524. ? $this->redirectToRoute('cvs_gestion_candidates_dashboard')
  525. : $this->redirectToRoute('locale_cvs_gestion_candidates_dashboard', ['_locale' => $locale]);
  526. }
  527. $repo = $this->em->getRepository(JobsHasLikes::class);
  528. $jhl = $repo->findOneBy(['candidate' => $candidate, 'job' => $job]);
  529. if ($jhl === null) {
  530. // LIKE
  531. $jhl = new JobsHasLikes();
  532. $jhl->setJob($job);
  533. $jhl->setCandidate($candidate);
  534. $jhl->setCreatedAt(new \DateTime('now'));
  535. $jhl->setUpdatedAt(new \DateTime('now'));
  536. $this->em->persist($jhl);
  537. $this->em->flush();
  538. $liked = true;
  539. } else {
  540. // UNLIKE (toggle)
  541. $this->em->remove($jhl);
  542. $this->em->flush();
  543. $liked = false;
  544. }
  545. if ($isAjax) {
  546. return new JsonResponse([
  547. 'success' => true,
  548. 'liked' => $liked,
  549. 'jobId' => $job->getId(),
  550. 'slug' => $job->getSlug(),
  551. ]);
  552. }
  553. // Comportement legacy non-AJAX : redirection dashboard candidat
  554. return $locale === 'en'
  555. ? $this->redirectToRoute('cvs_gestion_candidates_dashboard')
  556. : $this->redirectToRoute('locale_cvs_gestion_candidates_dashboard', ['_locale' => $locale]);
  557. }
  558. }