<?phpnamespace App\Controller\ThemesWebsite\Whileresume\Application;use App\Entity\Core\Users;use App\Entity\Cvs\Candidates;use App\Entity\Cvs\Enterprises;use App\Entity\Cvs\EnterprisesHasLikes;use App\Entity\Cvs\Jobs;use App\Entity\Cvs\JobsHasLikes;use App\Services\Core\RequestData;use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;use Symfony\Component\HttpFoundation\JsonResponse;use Symfony\Component\HttpFoundation\Request;use Symfony\Component\HttpFoundation\Response;use Symfony\Component\HttpFoundation\RedirectResponse;use Symfony\Component\Routing\Annotation\Route;use Doctrine\ORM\EntityManagerInterface;use Vich\UploaderBundle\Templating\Helper\UploaderHelper;class EnterprisesController extends AbstractController{ private $rd; private $em; private $uploaderHelper; public function __construct(RequestData $rd, EntityManagerInterface $em, UploaderHelper $uploaderHelper) { $this->rd = $rd; $this->em = $em; $this->uploaderHelper = $uploaderHelper; } /** * Page individuelle d'une entreprise — fiche détaillée + offres + similaires + sidebar. * URL : /{locale}/company/{slug} */ public function show(Request $request, $slug): Response { $locale = $request->getLocale(); $enterprise = $this->em->getRepository(Enterprises::class)->findOneBy([ 'slug' => $slug, 'locale' => $locale, 'online' => true ]); if ($enterprise === null) { throw $this->createNotFoundException('Entreprise introuvable'); } // Offres de cette entreprise $jobs = $this->em->getRepository(Jobs::class)->findBy([ 'enterprise' => $enterprise, 'online' => true ], ['createdAt' => 'DESC']); // Entreprises similaires (même catégorie ou même ville, max 5) $similarEnterprises = $this->em->getRepository(Enterprises::class) ->findSimilar($enterprise, 5); // Incrémente le compteur de vues try { $count = $enterprise->getViews() ?? 0; $enterprise->setViews($count + 1); $this->em->persist($enterprise); $this->em->flush(); } catch (\Throwable $e) { // Silent fail : on ne bloque pas l'affichage si le compteur de vues plante } // Filtres sidebar (depuis EnterprisesFilters — dédiés au scope entreprises) $sidebarFiltersCities = []; $sidebarFiltersCategories = []; try { $filtersRepo = $this->em->getRepository(\App\Entity\Cvs\EnterprisesFilters::class); $sidebarFilters = $filtersRepo->findActiveByLocale($locale, 30); foreach ($sidebarFilters as $f) { if ($f->getType() === 'city') { $sidebarFiltersCities[] = $f; } else { $sidebarFiltersCategories[] = $f; } } } catch (\Throwable $e) { // Si EnterprisesFilters n'est pas dispo, on continue sans } // ─────────────────────────────────────────────────────────────────── // Détection du type de viewer : 'anonymous' | 'candidate' | 'recruiter' // ─────────────────────────────────────────────────────────────────── $viewerType = $this->getViewerType(); $candidate = $this->getCurrentCandidate(); // isLiked + likedJobIds : seulement pertinents pour un candidat connecté $isLiked = false; $likedJobIds = []; if ($viewerType === 'candidate' && $candidate !== null) { // Like entreprise $isLiked = $this->em->getRepository(EnterprisesHasLikes::class)->findOneBy([ 'candidate' => $candidate, 'enterprise' => $enterprise, ]) !== null; // Likes des jobs de cette entreprise (pour les mini-cœurs) if (!empty($jobs)) { $jobLikes = $this->em->getRepository(JobsHasLikes::class)->createQueryBuilder('jhl') ->select('IDENTITY(jhl.job) AS jobId') ->where('jhl.candidate = :candidate') ->andWhere('jhl.job IN (:jobs)') ->setParameter('candidate', $candidate) ->setParameter('jobs', $jobs) ->getQuery() ->getArrayResult(); $likedJobIds = array_map('intval', array_column($jobLikes, 'jobId')); } } return $this->render('application/whileresume/application/enterprises/show.html.twig', [ 'slug' => $slug, 'enterprise' => $enterprise, 'jobs' => $jobs, 'similarEnterprises' => $similarEnterprises, 'sidebarFiltersCities' => $sidebarFiltersCities, 'sidebarFiltersCategories' => $sidebarFiltersCategories, 'viewerType' => $viewerType, // 'anonymous' | 'candidate' | 'recruiter' 'isLiked' => $isLiked, 'likedJobIds' => $likedJobIds, 'connectUser' => $this->getUser() !== null, // Backward-compat : on garde isCandidateUser pour ne pas casser d'autres templates 'isCandidateUser' => $viewerType === 'candidate', ]); } /** * Toggle like/unlike d'une entreprise par un candidat. * URL : /{locale}/company/{slug}/like * * Comportement : * - Anonyme → redirect vers show (ou JSON 401 si AJAX) * - Recruteur → 403 (action réservée aux candidats) * - Candidat → toggle, puis redirect (HTML) ou JSON {liked: bool} (AJAX) */ public function companyLike(Request $request, $slug): Response { $locale = $request->getLocale(); $isAjax = $request->isXmlHttpRequest(); $enterprise = $this->em->getRepository(Enterprises::class)->findOneBy([ 'slug' => $slug, 'locale' => $locale, 'online' => true, ]); if ($enterprise === null) { if ($isAjax) { return new JsonResponse(['error' => 'enterprise_not_found'], 404); } throw $this->createNotFoundException('Entreprise introuvable'); } $viewerType = $this->getViewerType(); // Recruteur connecté → action interdite if ($viewerType === 'recruiter') { if ($isAjax) { return new JsonResponse(['error' => 'forbidden_recruiter'], 403); } return $this->redirectToShow($locale, $slug); } // Anonyme → redirect vers show (la modal d'auth s'ouvrira via JS) if ($viewerType === 'anonymous') { if ($isAjax) { return new JsonResponse(['error' => 'unauthenticated'], 401); } return $this->redirectToShow($locale, $slug); } // → viewerType === 'candidate' $candidate = $this->getCurrentCandidate(); $likesRepo = $this->em->getRepository(EnterprisesHasLikes::class); $existing = $likesRepo->findOneBy([ 'candidate' => $candidate, 'enterprise' => $enterprise, ]); $nowLiked = false; if ($existing !== null) { $this->em->remove($existing); $nowLiked = false; } else { $like = new EnterprisesHasLikes(); $like->setCandidate($candidate); $like->setEnterprise($enterprise); $this->em->persist($like); $nowLiked = true; } $this->em->flush(); if ($isAjax) { return new JsonResponse(['liked' => $nowLiked]); } return $this->redirectToShow($locale, $slug); } /** * Toggle like/unlike d'un job depuis la fiche entreprise (AJAX uniquement). * URL : /{locale}/company/{slug}/job/{jobId}/like * * Endpoint dédié pour les mini-cœurs sur les cartes job de la fiche entreprise. * On ne réutilise pas JobsController::likeJob qui n'est pas un toggle et redirige * vers le dashboard candidat. */ public function jobLikeAjax(Request $request, $slug, int $jobId): JsonResponse { $viewerType = $this->getViewerType(); if ($viewerType === 'anonymous') { return new JsonResponse(['error' => 'unauthenticated'], 401); } if ($viewerType === 'recruiter') { return new JsonResponse(['error' => 'forbidden_recruiter'], 403); } $candidate = $this->getCurrentCandidate(); $job = $this->em->getRepository(Jobs::class)->find($jobId); if ($job === null) { return new JsonResponse(['error' => 'job_not_found'], 404); } $likesRepo = $this->em->getRepository(JobsHasLikes::class); $existing = $likesRepo->findOneBy([ 'candidate' => $candidate, 'job' => $job, ]); $nowLiked = false; if ($existing !== null) { $this->em->remove($existing); $nowLiked = false; } else { $like = new JobsHasLikes(); $like->setCandidate($candidate); $like->setJob($job); $this->em->persist($like); $nowLiked = true; } $this->em->flush(); return new JsonResponse([ 'liked' => $nowLiked, 'jobId' => $jobId, ]); } /** * Helper : redirect vers la fiche entreprise courante (gestion locale FR/EN). */ private function redirectToShow(string $locale, string $slug): RedirectResponse { return $this->redirectToRoute( $locale === 'en' ? 'cvs_application_company_show' : 'locale_cvs_application_company_show', $locale === 'en' ? ['slug' => $slug] : ['_locale' => $locale, 'slug' => $slug] ); } /** * Détermine le type de viewer : 'anonymous' | 'candidate' | 'recruiter'. * * - anonymous : pas connecté * - candidate : connecté ET a un Candidate associé * - recruiter : connecté MAIS sans Candidate (= recruteur, ou autre rôle non-candidat) */ private function getViewerType(): string { $user = $this->getUser(); if ($user === null) { return 'anonymous'; } return $this->getCurrentCandidate() !== null ? 'candidate' : 'recruiter'; } /** * Détermine si l'utilisateur connecté courant est un candidat. * @deprecated Utilisez getViewerType() === 'candidate' à la place. */ private function isCandidateUser(): bool { return $this->getCurrentCandidate() !== null; } /** * Récupère le candidat associé à l'utilisateur connecté (s'il y en a un). * * Note : la relation traverse depuis Users (qui a un getter getCandidate()) * et non depuis Candidates (qui n'a pas de propriété 'user' Doctrine). */ private function getCurrentCandidate(): ?Candidates { $user = $this->getUser(); if (!$user instanceof Users) { return null; } return method_exists($user, 'getCandidate') ? $user->getCandidate() : null; } // ═══════════════════════════════════════════════════════════════════ // DASHBOARD /companies — liste publique avec recherche + filtres // ═══════════════════════════════════════════════════════════════════ /** * Dashboard public /companies — liste avec recherche AJAX + chips villes/métiers. */ public function dashboard(Request $request): Response { $locale = $request->getLocale(); // Accepte ?q= ou ?search= $initialQuery = trim((string) $request->query->get('search', $request->query->get('q', ''))); // Filtres sidebar (depuis EnterprisesFilters — dédiés au scope entreprises) $sidebarFiltersCities = []; $sidebarFiltersCategories = []; $searchKeywords = []; try { $filtersRepo = $this->em->getRepository(\App\Entity\Cvs\EnterprisesFilters::class); $sidebarFilters = $filtersRepo->findActiveByLocale($locale, 30); foreach ($sidebarFilters as $f) { if ($f->getType() === 'city') { $sidebarFiltersCities[] = $f; } else { $sidebarFiltersCategories[] = $f; } } $keywordsRepo = $this->em->getRepository(\App\Entity\Cvs\KeywordsLandingJobs::class); $searchKeywords = $keywordsRepo->findByLocaleForHomepage($locale); } catch (\Throwable $e) { // OK si pas dispo } return $this->render('application/whileresume/application/enterprises/dashboard.html.twig', [ 'initialQuery' => $initialQuery, 'sidebarFiltersCities' => $sidebarFiltersCities, 'sidebarFiltersCategories' => $sidebarFiltersCategories, 'searchKeywords' => $searchKeywords, 'currentFilter' => null, 'currentCityFilter' => null, 'currentCityKeyword' => '', 'currentCategoryFilter' => null, 'currentCategoryKeyword' => '', 'currentSubKeyword' => null, 'currentSubKeywordSlug' => null, 'connectUser' => $this->getUser() !== null, 'isCandidateUser' => $this->isCandidateUser(), ]); } /** * Page landing /companies/{slug} : ville (scope j.city) ou catégorie (scope e.category). */ public function filterLanding(Request $request, $slug): Response { $locale = $request->getLocale(); $filtersRepo = $this->em->getRepository(\App\Entity\Cvs\EnterprisesFilters::class); $filter = $filtersRepo->findOneBySlugAndLocale($slug, $locale); if ($filter === null) { throw $this->createNotFoundException('Filtre introuvable'); } $keyword = $filter->getSearchKeyword() ?: $filter->getLabel(); $isCityContext = ($filter->getType() === 'city'); $isCategoryContext = !$isCityContext; // Sur une page ville/catégorie, on accepte ?search=texte (recherche libre dans le scope) $freeSearch = trim((string) $request->query->get('search', '')); $initialQuery = $freeSearch; // Sidebar filters $sidebarFiltersCities = []; $sidebarFiltersCategories = []; $searchKeywords = []; try { $sidebarFilters = $filtersRepo->findActiveByLocale($locale, 30); foreach ($sidebarFilters as $f) { if ($f->getType() === 'city') { $sidebarFiltersCities[] = $f; } else { $sidebarFiltersCategories[] = $f; } } $keywordsRepo = $this->em->getRepository(\App\Entity\Cvs\KeywordsLandingJobs::class); $searchKeywords = $keywordsRepo->findByLocaleForHomepage($locale); } catch (\Throwable $e) {} // Logger try { $statsRepo = $this->em->getRepository(\App\Entity\Cvs\SearchStats::class); $statsRepo->logSearch( $freeSearch, $isCityContext ? $keyword : null, $locale, $isCityContext ? 'company_landing_city' : 'company_landing_category' ); } catch (\Throwable $e) {} return $this->render('application/whileresume/application/enterprises/dashboard.html.twig', [ 'initialQuery' => $initialQuery, 'sidebarFiltersCities' => $sidebarFiltersCities, 'sidebarFiltersCategories' => $sidebarFiltersCategories, 'searchKeywords' => $searchKeywords, 'currentFilter' => $filter, 'currentCityFilter' => $isCityContext ? $filter : null, 'currentCityKeyword' => $isCityContext ? $keyword : '', 'currentCategoryFilter' => $isCategoryContext ? $filter : null, 'currentCategoryKeyword' => $isCategoryContext ? $keyword : '', 'currentSubKeyword' => null, 'currentSubKeywordSlug' => null, 'connectUser' => $this->getUser() !== null, 'isCandidateUser' => $this->isCandidateUser(), ]); } /** * Page combinée ville + keyword : /companies/{slug}/{keyword} * Ex: /fr/companies/paris/restauration */ public function filterLandingKeyword(Request $request, $slug, $keyword): Response { $locale = $request->getLocale(); $filtersRepo = $this->em->getRepository(\App\Entity\Cvs\EnterprisesFilters::class); $filter = $filtersRepo->findOneBySlugAndLocale($slug, $locale); if ($filter === null || $filter->getType() !== 'city') { throw $this->createNotFoundException('Filtre ville introuvable'); } $cityKeyword = $filter->getSearchKeyword() ?: $filter->getLabel(); // Le keyword peut matcher un KeywordsLandingJobs ou être libre $keywordsRepo = $this->em->getRepository(\App\Entity\Cvs\KeywordsLandingJobs::class); $searchKeywords = $keywordsRepo->findByLocaleForHomepage($locale); $matchedKeyword = null; foreach ($searchKeywords as $kw) { if ($this->slugify($kw->getLabel()) === $keyword) { $matchedKeyword = $kw; break; } } $subKeywordQuery = $matchedKeyword !== null ? ($matchedKeyword->getSearchKeyword() ?: $matchedKeyword->getLabel()) : str_replace('-', ' ', $keyword); // Sidebar $sidebarFiltersCities = []; $sidebarFiltersCategories = []; try { $sidebarFilters = $filtersRepo->findActiveByLocale($locale, 30); foreach ($sidebarFilters as $f) { if ($f->getType() === 'city') { $sidebarFiltersCities[] = $f; } else { $sidebarFiltersCategories[] = $f; } } } catch (\Throwable $e) {} // Logger try { $statsRepo = $this->em->getRepository(\App\Entity\Cvs\SearchStats::class); $statsRepo->logSearch($subKeywordQuery, $cityKeyword, $locale, 'company_landing_keyword'); } catch (\Throwable $e) {} return $this->render('application/whileresume/application/enterprises/dashboard.html.twig', [ 'initialQuery' => $subKeywordQuery, 'sidebarFiltersCities' => $sidebarFiltersCities, 'sidebarFiltersCategories' => $sidebarFiltersCategories, 'searchKeywords' => $searchKeywords, 'currentFilter' => $filter, 'currentCityFilter' => $filter, 'currentCityKeyword' => $cityKeyword, 'currentCategoryFilter' => null, 'currentCategoryKeyword' => '', 'currentSubKeyword' => $matchedKeyword !== null ? $matchedKeyword->getLabel() : ucfirst(str_replace('-', ' ', $keyword)), 'currentSubKeywordSlug' => $keyword, 'connectUser' => $this->getUser() !== null, 'isCandidateUser' => $this->isCandidateUser(), ]); } /** * API publique de recherche d'entreprises pour le dashboard. * GET /companies/search?q=&city=&category=&locale=&offset=&limit= */ 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', 10))); $offset = max(0, (int) $request->query->get('offset', 0)); $city = trim((string) $request->query->get('city', '')); $category = trim((string) $request->query->get('category', '')); $cityKeyword = $city !== '' ? $city : null; $categoryKeyword = $category !== '' ? $category : null; // Si la query fait moins de 2 chars, on l'ignore (le filtre full-text sauterait sur des mots trop courts). // Mais on retourne quand même les entreprises (toutes ou scopées par city/category si présents). $effectiveQuery = mb_strlen($query) < 2 ? '' : $query; $repo = $this->em->getRepository(Enterprises::class); $enterprises = $repo->searchEnterprises($effectiveQuery, $locale, $offset, $limit, $cityKeyword, $categoryKeyword); $total = $repo->countSearchEnterprises($effectiveQuery, $locale, $cityKeyword, $categoryKeyword); // Logger uniquement à la première page ET si query meaningful (≥ 2 chars) ou un scope if ($offset === 0 && ($effectiveQuery !== '' || $cityKeyword !== null || $categoryKeyword !== null)) { try { $statsRepo = $this->em->getRepository(\App\Entity\Cvs\SearchStats::class); $statsRepo->logSearch($effectiveQuery, $cityKeyword, $locale, 'company_submit'); } catch (\Throwable $e) {} } $items = []; foreach ($enterprises as $e) { // Logo : utilise vich UploaderHelper (équivalent PHP de vich_uploader_asset() en Twig) // Ça lit la config vich (mapping enterprises_logo) pour générer la bonne URL, // au lieu du chemin en dur '/files/cvs/...' qui dépend de cette config. $logo = null; if ($e->getImage() !== null && $e->getImage()->getName()) { $logo = $this->uploaderHelper->asset($e, 'imageFile'); } $items[] = [ 'id' => $e->getId(), 'slug' => $e->getSlug(), 'name' => $e->getCompanyName(), 'shortTitle' => $e->getShortTitle(), 'category' => $e->getCategory(), 'city' => $e->getCity(), 'country' => $e->getCountry(), 'logo' => $logo, 'firstLetter' => $e->getCompanyName() ? mb_strtoupper(mb_substr($e->getCompanyName(), 0, 1)) : '?', 'url' => $this->generateUrl('locale_cvs_application_company_show', [ '_locale' => $locale, 'slug' => $e->getSlug(), ]), ]; } return new JsonResponse([ 'items' => $items, 'total' => $total, 'query' => $query, 'city' => $cityKeyword, 'category' => $categoryKeyword, 'limit' => $limit, 'offset' => $offset, ]); } /** * Slugifie une chaîne (ex: "Café & Restauration" → "cafe-and-restauration") * 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 $text = strtr($text, [ '&' => '-and-', '+' => '-plus-', 'œ' => 'oe', 'Œ' => 'oe', 'æ' => 'ae', 'Æ' => 'ae', ]); if (function_exists('transliterator_transliterate')) { $text = transliterator_transliterate('Any-Latin; Latin-ASCII; Lower()', $text); } else { $text = strtolower(iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $text)); } $text = preg_replace('/[^a-z0-9]+/', '-', $text); return trim($text, '-'); }}