<?phpnamespace App\Controller\ThemesWebsite\Whileresume\Application;use App\Entity\Core\Users;use App\Entity\Cvs\Candidates;use App\Entity\Cvs\CandidatesHasExperiences;use App\Entity\Cvs\CandidatesHasProjects;use App\Entity\Cvs\CandidatesHasServices;use App\Entity\Cvs\CandidatesHasSkills;use App\Entity\Cvs\Jobs;use App\Entity\Cvs\JobsHasLikes;use App\Form\Core\UsersType;use App\Form\Cvs\JobsForm;use App\Form\Cvs\JobsFrForm;use App\Services\Core\RequestData;use Sensio\Bundle\FrameworkExtraBundle\Configuration\Cache;use Sensio\Bundle\FrameworkExtraBundle\Configuration\IsGranted;use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;use Symfony\Component\EventDispatcher\EventDispatcherInterface;use Symfony\Component\HttpFoundation\Request;use Symfony\Component\HttpFoundation\Response;use Symfony\Component\Routing\Annotation\Route;use Symfony\Component\HttpFoundation\Cookie;use Symfony\Component\HttpFoundation\JsonResponse;use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;use Doctrine\ORM\EntityManagerInterface;class JobsController extends AbstractController{ private $rd; private $em; public function __construct(RequestData $rd, EntityManagerInterface $em ) { $this->rd = $rd; $this->em = $em; } public function new(Request $request): Response { $locale = $request->getLocale(); $user = $this->getUser(); $jobClass = JobsForm::class; if($locale == "fr") { $jobClass = JobsFrForm::class; } $job = new Jobs(); $form = $this->createForm($jobClass, $job); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { $job->setSlug("0"); $job->setLocale($locale); $job->setValidation(false); $job->setVerification(false); $job->setOnline(false); $job->setEnterprise(null); $job->setUser($user); $this->em->persist($job); $this->em->flush(); if($job->getSlug() != "0") { $job->setSlug($this->generateSlug($job->getId())); $this->em->persist($job); $this->em->flush(); } if($locale !== "en") { return $this->redirectToRoute('locale_cvs_application_job_new_confirm',['_locale' => $locale]); } return $this->redirectToRoute('cvs_application_job_new_confirm'); } return $this->render('application/whileresume/application/jobs/new_'.$locale.'.html.twig',[ 'form' => $form->createView(), ]); } private function generateSlug(int $id): string { $letters = 'abcdefghijklmnopqrstuvwxyz'; $random = ''; for ($i = 0; $i < 6; $i++) { $random .= $letters[random_int(0, 25)]; } return $random . '-' . $id; } public function show(Request $request, $slug): Response { $locale = $request->getLocale(); $jobsRepo = $this->em->getRepository(Jobs::class); $job = $jobsRepo->findOneBy(['locale' => $locale, 'slug' => $slug, 'online' => true, 'validation' => true]); if($job === null) { throw $this->createNotFoundException('Page non trouvée'); } $othersJobs = null; if($job->getEnterprise() != null) { $othersJobs = $jobsRepo->findBy(['enterprise' => $job->getEnterprise(), 'online' => true]); } // Offres similaires basées sur catégorie, compétences, ville, titre $similarJobs = $jobsRepo->findSimilarJobs($job, 5); // Filtres sidebar : tags polymorphes (villes + métiers) configurés en BDD $filtersRepo = $this->em->getRepository(\App\Entity\Cvs\JobsFilters::class); $sidebarFilters = $filtersRepo->findActiveByLocale($locale, 30); // Séparer par type pour l'affichage (city vs category) $sidebarFiltersCities = []; $sidebarFiltersCategories = []; foreach ($sidebarFilters as $f) { if ($f->getType() === 'city') { $sidebarFiltersCities[] = $f; } else { $sidebarFiltersCategories[] = $f; } } // Keywords cliquables sous la barre de recherche (homepage de la locale) $keywordsRepo = $this->em->getRepository(\App\Entity\Cvs\KeywordsLandingJobs::class); $searchKeywords = $keywordsRepo->findByLocaleForHomepage($locale); $count = 0; if($job->getViews() != null) { $count = $job->getViews() + 1; } $job->setViews($count); $this->em->persist($job); $this->em->flush(); // État du like pour le candidat connecté (rendu initial du bouton) $isLiked = false; $currentUser = $this->getUser(); if ($currentUser !== null && $currentUser->getCandidate() !== null) { $existingLike = $this->em->getRepository(JobsHasLikes::class) ->findOneBy(['candidate' => $currentUser->getCandidate(), 'job' => $job]); $isLiked = $existingLike !== null; } return $this->render('application/whileresume/application/jobs/show.html.twig',[ 'slug' => $slug, 'job' => $job, 'othersJobs' => $othersJobs, 'similarJobs' => $similarJobs, 'sidebarFiltersCities' => $sidebarFiltersCities, 'sidebarFiltersCategories' => $sidebarFiltersCategories, 'searchKeywords' => $searchKeywords, 'isLiked' => $isLiked, ]); } /** * Retourne les détails d'une offre en JSON pour la fiche modale (AJAX). * Si l'utilisateur n'est pas connecté, retourne uniquement les champs publics * (résumé + meta) avec un flag "locked" sur les détails. */ public function ajaxShow(Request $request, $slug): JsonResponse { $locale = $request->getLocale(); $user = $this->getUser(); $job = $this->em->getRepository(Jobs::class)->findOneBy([ 'locale' => $locale, 'slug' => $slug, 'online' => true, 'validation' => true ]); if ($job === null) { return new JsonResponse(['error' => 'not_found'], 404); } $isAuthenticated = $user !== null; $isCandidate = $isAuthenticated && $user->getCandidate() !== null; $enterpriseData = null; if ($job->getEnterprise() !== null) { $enterpriseData = [ 'id' => $job->getEnterprise()->getId(), 'slug' => $job->getEnterprise()->getSlug(), 'name' => $job->getEnterprise()->getCompanyName(), 'logo' => method_exists($job->getEnterprise(), 'getLogo') ? $job->getEnterprise()->getLogo() : null, 'city' => method_exists($job->getEnterprise(), 'getCity') ? $job->getEnterprise()->getCity() : null, 'country' => method_exists($job->getEnterprise(), 'getCountry') ? $job->getEnterprise()->getCountry() : null, ]; } // Données publiques (toujours visibles) $payload = [ 'id' => $job->getId(), 'slug' => $job->getSlug(), 'title' => $job->getJobTitle(), 'companyName' => $job->getCompanyName(), 'category' => $job->getCategory(), 'city' => $job->getCity(), 'country' => $job->getCountry(), 'employmentType' => $job->getEmploymentType(), 'remoteWork' => $job->getRemoteWork(), 'experienceLevel' => $job->getExperienceLevel(), 'salaryMin' => $job->getSalaryMin(), 'salaryMax' => $job->getSalaryMax(), 'salaryPeriod' => $job->getSalaryPeriod(), 'devise' => $job->getDevise(), 'jobSummary' => $job->getJobSummary(), 'verification' => $job->isVerification(), 'website' => method_exists($job, 'getWebsite') ? $job->getWebsite() : null, 'websearch' => method_exists($job, 'getWebsearch') ? $job->getWebsearch() : null, 'enterprise' => $enterpriseData, 'isAuthenticated' => $isAuthenticated, 'isCandidate' => $isCandidate, 'locked' => !$isAuthenticated, 'showUrl' => $locale === 'en' ? $this->generateUrl('cvs_application_job_show', ['slug' => $slug]) : $this->generateUrl('locale_cvs_application_job_show', ['_locale' => $locale, 'slug' => $slug]), 'likeUrl' => null, ]; // Données détaillées (uniquement si connecté) if ($isAuthenticated) { $payload['keyResponsabilities'] = $job->getKeyResponsabilities(); $payload['requirements'] = $job->getRequirements(); $payload['benefits'] = $job->getBenefits(); if ($isCandidate) { $payload['likeUrl'] = $locale === 'en' ? $this->generateUrl('cvs_application_job_like', ['slug' => $slug]) : $this->generateUrl('locale_cvs_application_job_like', ['_locale' => $locale, 'slug' => $slug]); // État du like pour ce candidat (utilisé par buildJobHtml côté JS swipe) $existingLike = $this->em->getRepository(JobsHasLikes::class) ->findOneBy(['candidate' => $user->getCandidate(), 'job' => $job]); $payload['isLiked'] = $existingLike !== null; } else { $payload['isLiked'] = false; } } else { $payload['isLiked'] = false; } return new JsonResponse($payload); } /** * Page dashboard / liste publique des offres. * URL : /{locale}/jobs * * Affiche la sidebar Sociala (filtres villes/métiers + chips keywords) * + une liste des offres alimentée en AJAX via apiSearch (avec "Charger plus"). */ public function dashboard(Request $request): Response { $locale = $request->getLocale(); // Accepte ?q= (legacy) ou ?search= (nouveau, pour les fallbacks depuis les pages ville) $initialQuery = trim((string) $request->query->get('search', $request->query->get('q', ''))); // Filtres sidebar (villes / métiers) $filtersRepo = $this->em->getRepository(\App\Entity\Cvs\JobsFilters::class); $sidebarFilters = $filtersRepo->findActiveByLocale($locale, 30); $sidebarFiltersCities = []; $sidebarFiltersCategories = []; foreach ($sidebarFilters as $f) { if ($f->getType() === 'city') { $sidebarFiltersCities[] = $f; } else { $sidebarFiltersCategories[] = $f; } } // Keywords cliquables sous la barre de recherche $keywordsRepo = $this->em->getRepository(\App\Entity\Cvs\KeywordsLandingJobs::class); $searchKeywords = $keywordsRepo->findByLocaleForHomepage($locale); return $this->render('application/whileresume/application/jobs/dashboard.html.twig', [ 'initialQuery' => $initialQuery, 'sidebarFiltersCities' => $sidebarFiltersCities, 'sidebarFiltersCategories' => $sidebarFiltersCategories, 'searchKeywords' => $searchKeywords, 'currentCityFilter' => null, 'currentCityKeyword' => '', 'currentSubKeyword' => null, 'currentSubKeywordSlug' => null, ]); } /** * Page landing pour un filtre (ville/métier/etc.) configuré dans cvs_jobsfilters. * URL : /{locale}/jobs/{slug} → ex: /fr/jobs/paris ou /fr/jobs/developpement-web * * Rend le même template que le dashboard, avec : * - le filtre actif marqué (chip violet plein) * - le searchKeyword pré-rempli dans la barre de recherche * - la liste pré-filtrée sur ce keyword */ public function filterLanding(Request $request, $slug): Response { $locale = $request->getLocale(); $filtersRepo = $this->em->getRepository(\App\Entity\Cvs\JobsFilters::class); $filter = $filtersRepo->findOneBySlugAndLocale($slug, $locale); if ($filter === null) { throw $this->createNotFoundException('Filtre introuvable'); } // Le keyword utilisé pour la recherche (pré-rempli dans la barre) $keyword = $filter->getSearchKeyword() ?: $filter->getLabel(); // Filtres sidebar $sidebarFilters = $filtersRepo->findActiveByLocale($locale, 30); $sidebarFiltersCities = []; $sidebarFiltersCategories = []; foreach ($sidebarFilters as $f) { if ($f->getType() === 'city') { $sidebarFiltersCities[] = $f; } else { $sidebarFiltersCategories[] = $f; } } // Keywords cliquables sous la barre de recherche $keywordsRepo = $this->em->getRepository(\App\Entity\Cvs\KeywordsLandingJobs::class); $searchKeywords = $keywordsRepo->findByLocaleForHomepage($locale); // Si le filtre est une VILLE → on contraint les recherches à cette ville // Sinon (catégorie/métier) → c'est juste un préfiltre keyword classique $isCityContext = ($filter->getType() === 'city'); // Sur une page ville, on accepte aussi ?search=texte (recherche libre dans la ville) $freeSearch = $isCityContext ? trim((string) $request->query->get('search', '')) : ''; $initialQuery = $isCityContext ? $freeSearch // search libre prioritaire sinon vide : $keyword; // catégorie/métier → préfiltre par défaut // Logger la visite de cette page landing try { $statsRepo = $this->em->getRepository(\App\Entity\Cvs\SearchStats::class); if ($isCityContext) { // Page ville : on log la ville (+ recherche libre si fournie) $statsRepo->logSearch($freeSearch, $keyword, $locale, 'landing_city'); } else { // Page catégorie/métier : on log le keyword comme query $statsRepo->logSearch($keyword, null, $locale, 'landing_keyword'); } } catch (\Throwable $e) { /* silent fail */ } return $this->render('application/whileresume/application/jobs/dashboard.html.twig', [ 'initialQuery' => $initialQuery, 'sidebarFiltersCities' => $sidebarFiltersCities, 'sidebarFiltersCategories' => $sidebarFiltersCategories, 'searchKeywords' => $searchKeywords, 'currentFilter' => $filter, // pour SEO + chip active 'currentCityFilter' => $isCityContext ? $filter : null, // contrainte ville pour la recherche 'currentCityKeyword' => $isCityContext ? $keyword : '', // keyword utilisé pour matcher j.city 'currentSubKeyword' => null, // pas de sous-keyword sur cette page 'currentSubKeywordSlug' => null, ]); } /** * Page combinée ville + keyword : /jobs/{slug}/{keyword} * Ex: /fr/jobs/paris/developpeur → toutes les offres "développeur" À PARIS * * Le {slug} doit correspondre à un filtre de type 'city' actif. Sinon 404. * Le {keyword} est un slug libre, qui peut correspondre à un KeywordsLandingJobs ou pas. */ public function filterLandingKeyword(Request $request, $slug, $keyword): Response { $locale = $request->getLocale(); $filtersRepo = $this->em->getRepository(\App\Entity\Cvs\JobsFilters::class); $filter = $filtersRepo->findOneBySlugAndLocale($slug, $locale); if ($filter === null || $filter->getType() !== 'city') { throw $this->createNotFoundException('Filtre ville introuvable'); } // Recherche : on combine la ville (contrainte j.city) + le keyword (recherche full-text) $cityKeyword = $filter->getSearchKeyword() ?: $filter->getLabel(); // Le keyword peut soit correspondre à un KeywordsLandingJobs (BDD), soit être libre $keywordsRepo = $this->em->getRepository(\App\Entity\Cvs\KeywordsLandingJobs::class); $searchKeywords = $keywordsRepo->findByLocaleForHomepage($locale); // Cherche dans les keywords config si le slug en URL matche $matchedKeyword = null; foreach ($searchKeywords as $kw) { if ($this->slugify($kw->getLabel()) === $keyword) { $matchedKeyword = $kw; break; } } // Si match trouvé : on prend le searchKeyword/label défini ; sinon : on utilise le slug brut comme query $subKeywordQuery = $matchedKeyword !== null ? ($matchedKeyword->getSearchKeyword() ?: $matchedKeyword->getLabel()) : str_replace('-', ' ', $keyword); // Filtres sidebar $sidebarFilters = $filtersRepo->findActiveByLocale($locale, 30); $sidebarFiltersCities = []; $sidebarFiltersCategories = []; foreach ($sidebarFilters as $f) { if ($f->getType() === 'city') { $sidebarFiltersCities[] = $f; } else { $sidebarFiltersCategories[] = $f; } } // Logger la visite : query = keyword, ville = cityKeyword try { $statsRepo = $this->em->getRepository(\App\Entity\Cvs\SearchStats::class); $statsRepo->logSearch($subKeywordQuery, $cityKeyword, $locale, 'landing_keyword'); } catch (\Throwable $e) { /* silent fail */ } return $this->render('application/whileresume/application/jobs/dashboard.html.twig', [ 'initialQuery' => $subKeywordQuery, 'sidebarFiltersCities' => $sidebarFiltersCities, 'sidebarFiltersCategories' => $sidebarFiltersCategories, 'searchKeywords' => $searchKeywords, 'currentFilter' => $filter, 'currentCityFilter' => $filter, 'currentCityKeyword' => $cityKeyword, 'currentSubKeyword' => $matchedKeyword !== null ? $matchedKeyword->getLabel() : ucfirst(str_replace('-', ' ', $keyword)), 'currentSubKeywordSlug' => $keyword, ]); } /** * Slugifie une chaîne (ex: "Développeur Web" → "developpeur-web") * Utilisé pour matcher les keywords URL avec ceux de la BDD. * IMPORTANT : doit produire EXACTEMENT le même slug que le filtre Twig * utilisé dans dashboard.html.twig pour générer les liens des chips. */ private function slugify(string $text): string { // Pré-remplacement des caractères spéciaux SÉMANTIQUES (avant la translitération) // pour rester cohérent avec le Twig qui traite '&' → '-and-', '+' → '-plus-', // 'œ' → 'oe', 'æ' → 'ae' (Latin-ASCII les remplace souvent par '' tout court) $text = strtr($text, [ '&' => '-and-', '+' => '-plus-', 'œ' => 'oe', 'Œ' => 'oe', 'æ' => 'ae', 'Æ' => 'ae', ]); // Translitération des accents → ASCII + lowercase if (function_exists('transliterator_transliterate')) { $text = transliterator_transliterate('Any-Latin; Latin-ASCII; Lower()', $text); } else { // Fallback : iconv $text = strtolower(iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $text)); } // Remplace tout ce qui n'est pas alphanumérique par un tiret $text = preg_replace('/[^a-z0-9]+/', '-', $text); // Trim les tirets en début/fin return trim($text, '-'); } /** * API publique de recherche d'offres pour l'autocomplétion. * GET /api/jobs/search?q=...&locale=...&limit=...&city=... */ public function apiSearch(Request $request): JsonResponse { $query = trim((string) $request->query->get('q', '')); $locale = $request->query->get('locale', $request->getLocale()); $limit = max(1, min(20, (int) $request->query->get('limit', 6))); $offset = max(0, (int) $request->query->get('offset', 0)); $city = trim((string) $request->query->get('city', '')); $cityKeyword = $city !== '' ? $city : null; // Mode "tout afficher" : utilisé par la page dashboard pour lister // les dernières offres quand aucun filtre n'est appliqué. // (Les widgets d'autocomplétion ne passent PAS ce flag : ils gardent // l'ancien comportement "pas de spam tant que la query fait < 2 chars".) $allowEmpty = filter_var($request->query->get('all', '0'), FILTER_VALIDATE_BOOLEAN); // Si on a une ville mais pas de query, on autorise (la ville suffit comme filtre) // Si on demande explicitement "toutes les offres" (dashboard), on autorise aussi if (mb_strlen($query) < 2 && $cityKeyword === null && !$allowEmpty) { return new JsonResponse([ 'items' => [], 'total' => 0, 'query' => $query, ]); } $repo = $this->em->getRepository(Jobs::class); $jobs = $repo->searchJobs($query, $locale, $offset, $limit, $cityKeyword); $total = $repo->countSearchJobs($query, $locale, $cityKeyword); // Logger la recherche (uniquement au 1er chargement, pas en pagination) if ($offset === 0) { try { $statsRepo = $this->em->getRepository(\App\Entity\Cvs\SearchStats::class); $statsRepo->logSearch($query, $cityKeyword, $locale, 'submit'); } catch (\Throwable $e) { /* silent fail */ } } $items = []; foreach ($jobs as $job) { $logo = null; if ($job->getEnterprise() !== null && method_exists($job->getEnterprise(), 'getLogo')) { $logo = $job->getEnterprise()->getLogo(); } $items[] = [ 'id' => $job->getId(), 'slug' => $job->getSlug(), 'title' => $job->getJobTitle(), 'companyName' => $job->getCompanyName(), 'category' => $job->getCategory(), 'city' => $job->getCity(), 'country' => $job->getCountry(), 'employmentType' => $job->getEmploymentType(), 'salaryMin' => $job->getSalaryMin(), 'salaryMax' => $job->getSalaryMax(), 'devise' => $job->getDevise(), 'logo' => $logo, 'url' => $locale === 'en' ? $this->generateUrl('cvs_application_job_show', ['slug' => $job->getSlug()]) : $this->generateUrl('locale_cvs_application_job_show', ['_locale' => $locale, 'slug' => $job->getSlug()]), ]; } return new JsonResponse([ 'items' => $items, 'total' => $total, 'query' => $query, 'city' => $cityKeyword, 'limit' => $limit, 'offset' => $offset, ]); } /** * Like / unlike (toggle) d'une offre par le candidat connecté. * * - Requête AJAX (X-Requested-With: XMLHttpRequest ou Accept: application/json) * → retourne JSON {success, liked, jobId, slug} * - Requête classique (GET direct depuis un <a>) * → comportement legacy : redirection vers le dashboard candidat * * Cas d'erreur AJAX : * - 401 unauthenticated : utilisateur non connecté * - 403 no_candidate_profile: connecté mais sans profil candidat * - 404 not_found : offre introuvable */ public function likeJob(Request $request, $slug): Response { $locale = $request->getLocale(); $user = $this->getUser(); $isAjax = $request->isXmlHttpRequest() || str_contains((string) $request->headers->get('Accept'), 'application/json'); // Non connecté if ($user === null) { if ($isAjax) { return new JsonResponse([ 'success' => false, 'error' => 'unauthenticated', 'loginUrl' => $locale === 'en' ? $this->generateUrl('app_login') : $this->generateUrl('locale_app_login', ['_locale' => $locale]), ], 401); } return $locale === 'en' ? $this->redirectToRoute('cvs_gestion_candidates_dashboard') : $this->redirectToRoute('locale_cvs_gestion_candidates_dashboard', ['_locale' => $locale]); } $candidate = $user->getCandidate(); $job = $this->em->getRepository(Jobs::class)->findOneBy(['slug' => $slug]); if ($job === null) { if ($isAjax) { return new JsonResponse(['success' => false, 'error' => 'not_found'], 404); } throw $this->createNotFoundException('Offre introuvable'); } if ($candidate === null) { if ($isAjax) { return new JsonResponse(['success' => false, 'error' => 'no_candidate_profile'], 403); } return $locale === 'en' ? $this->redirectToRoute('cvs_gestion_candidates_dashboard') : $this->redirectToRoute('locale_cvs_gestion_candidates_dashboard', ['_locale' => $locale]); } $repo = $this->em->getRepository(JobsHasLikes::class); $jhl = $repo->findOneBy(['candidate' => $candidate, 'job' => $job]); if ($jhl === null) { // LIKE $jhl = new JobsHasLikes(); $jhl->setJob($job); $jhl->setCandidate($candidate); $jhl->setCreatedAt(new \DateTime('now')); $jhl->setUpdatedAt(new \DateTime('now')); $this->em->persist($jhl); $this->em->flush(); $liked = true; } else { // UNLIKE (toggle) $this->em->remove($jhl); $this->em->flush(); $liked = false; } if ($isAjax) { return new JsonResponse([ 'success' => true, 'liked' => $liked, 'jobId' => $job->getId(), 'slug' => $job->getSlug(), ]); } // Comportement legacy non-AJAX : redirection dashboard candidat return $locale === 'en' ? $this->redirectToRoute('cvs_gestion_candidates_dashboard') : $this->redirectToRoute('locale_cvs_gestion_candidates_dashboard', ['_locale' => $locale]); }}