src/Controller/HomeController.php line 31

Open in your IDE?
  1. <?php
  2. namespace App\Controller;
  3. use App\Entity\HelpMission;
  4. use App\Entity\InterestActivity;
  5. use App\Entity\InterestActivityRegistration;
  6. use App\Entity\Notification;
  7. use App\Entity\ThematicGroup;
  8. use App\Entity\ThematicGroupMember;
  9. use App\Entity\User;
  10. use App\Manager\EventNotificationManager;
  11. use App\Repository\HelpMissionRepository;
  12. use App\Repository\InterestActivityRegistrationRepository;
  13. use App\Repository\InterestActivityRepository;
  14. use App\Repository\ThematicGroupRepository;
  15. use App\Service\AgendaReminderService;
  16. use App\Service\ContentLifecycleManager;
  17. use App\Service\ContentConversationManager;
  18. use Doctrine\ORM\EntityManagerInterface;
  19. use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
  20. use Symfony\Component\HttpFoundation\JsonResponse;
  21. use Symfony\Component\HttpFoundation\Request;
  22. use Symfony\Component\HttpFoundation\Response;
  23. use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
  24. use Symfony\Component\Routing\Annotation\Route;
  25. class HomeController extends AbstractController
  26. {
  27.     #[Route('/'name'app_home')]
  28.     public function index(): Response
  29.     {
  30.         return $this->render('home/index.html.twig');
  31.     }
  32.     // #[Route('/', name: 'app_home')]
  33.     // public function index(Request $request): Response
  34.     // {
  35.     //     return $this->forward('App\Controller\W1Controller::home');
  36.     //     // $response = $this->render('home/index.html.twig', [], new Response());
  37.     //     // $response->setPublic();
  38.     //     // $response->setMaxAge(60);
  39.     //     // $response->setSharedMaxAge(300);
  40.     //     // return $response;
  41.     // }
  42.     #[Route('/mockup'name'app_mockup')]
  43.     public function mockup(): Response
  44.     {
  45.         return $this->render('home/mockup.html.twig');
  46.     }
  47.     #[Route('/api/home/feed'name'app_home_feed'methods: ['GET'])]
  48.     public function feed(Request $requestEntityManagerInterface $entityManager): JsonResponse
  49.     {
  50.         $allowedTypes = ['all''activity''mission''group'];
  51.         $selectedType strtolower(trim((string) $request->query->get('type''all')));
  52.         if (!in_array($selectedType$allowedTypestrue)) {
  53.             $selectedType 'all';
  54.         }
  55.         $selectedLocation $this->normalizeLocation((string) $request->query->get('location'''));
  56.         $page max(1, (int) $request->query->get('page'1));
  57.         $perPage max(6min(30, (int) $request->query->get('perPage'12)));
  58.         $offset = ($page 1) * $perPage;
  59.         $now = new \DateTimeImmutable();
  60.         $archiveThreshold ContentLifecycleManager::archiveThreshold($now);
  61.         // On charge un volume raisonnable par type puis on trie globalement.
  62.         $limitPerType max(40min(250, ($page $perPage) + 40));
  63.         $posts = [];
  64.         /** @var User|null $currentUser */
  65.         $currentUser $this->getUser() instanceof User $this->getUser() : null;
  66.         $joinedPosts $this->buildJoinedPostIndex($entityManager$currentUser);
  67.         $activityRows $entityManager->createQueryBuilder()
  68.             ->select('a.id AS id, a.title AS title, a.description AS description, a.city AS city, a.createdAt AS createdAt, a.updatedAt AS updatedAt, a.startAt AS startAt, a.endAt AS endAt, a.status AS status, u.id AS userId, u.username AS username, u.image AS userImage')
  69.             ->from(InterestActivity::class, 'a')
  70.             ->innerJoin('a.organizer''u')
  71.             ->where('((a.status = :activityStatus AND (a.endAt IS NULL OR a.endAt > :now)) OR (a.status IN (:activityArchivedStatuses) AND COALESCE(a.updatedAt, a.endAt, a.createdAt) > :archiveThreshold))')
  72.             ->setParameter('activityStatus'InterestActivity::STATUS_OPEN)
  73.             ->setParameter('activityArchivedStatuses', [InterestActivity::STATUS_CLOSEDInterestActivity::STATUS_CANCELED])
  74.             ->setParameter('now'$now)
  75.             ->setParameter('archiveThreshold'$archiveThreshold)
  76.             ->orderBy('a.createdAt''DESC')
  77.             ->setMaxResults($limitPerType)
  78.             ->getQuery()
  79.             ->getArrayResult();
  80.         foreach ($activityRows as $row) {
  81.             $sourceId = (int) $row['id'];
  82.             $isJoined in_array($sourceId$joinedPosts['activity'], true);
  83.             $archiveBadge $this->resolveArchiveBadge((string) ($row['status'] ?? ''), $row['endAt'] ?? null$now);
  84.             $posts[] = $this->buildPost(
  85.                 'activity',
  86.                 $sourceId,
  87.                 (string) ($row['username'] ?? 'Utilisateur'),
  88.                 (string) ($row['title'] ?? ''),
  89.                 (string) ($row['description'] ?? ''),
  90.                 $row['createdAt'] ?? null,
  91.                 '/activities/interet/' $sourceId,
  92.                 $this->resolveProfileUrl($row),
  93.                 $this->resolveAvatarUrl($row),
  94.                 '%s propose l activite "%s".',
  95.                 (string) ($row['city'] ?? ''),
  96.                 !$isJoined && $archiveBadge === null,
  97.                 'creation',
  98.                 $isJoined,
  99.                 $archiveBadge,
  100.                 $row['startAt'] ?? null,
  101.                 $row['endAt'] ?? null
  102.             );
  103.         }
  104.         $missionRows $entityManager->createQueryBuilder()
  105.             ->select('m.id AS id, m.title AS title, m.description AS description, m.city AS city, m.createdAt AS createdAt, m.updatedAt AS updatedAt, m.endAt AS endAt, m.status AS status, u.id AS userId, u.username AS username, u.image AS userImage')
  106.             ->from(HelpMission::class, 'm')
  107.             ->innerJoin('m.creator''u')
  108.             ->where('((m.status IN (:missionStatuses) AND (m.endAt IS NULL OR m.endAt > :missionNow)) OR (m.status = :missionDoneStatus AND COALESCE(m.updatedAt, m.endAt, m.createdAt) > :archiveThreshold))')
  109.             ->setParameter('missionStatuses', [HelpMission::STATUS_OPENHelpMission::STATUS_IN_PROGRESS])
  110.             ->setParameter('missionDoneStatus'HelpMission::STATUS_DONE)
  111.             ->setParameter('missionNow'$now)
  112.             ->setParameter('archiveThreshold'$archiveThreshold)
  113.             ->orderBy('m.createdAt''DESC')
  114.             ->setMaxResults($limitPerType)
  115.             ->getQuery()
  116.             ->getArrayResult();
  117.         foreach ($missionRows as $row) {
  118.             $sourceId = (int) $row['id'];
  119.             $isJoined in_array($sourceId$joinedPosts['mission'], true);
  120.             $archiveBadge $this->resolveArchiveBadge((string) ($row['status'] ?? ''), $row['endAt'] ?? null$now);
  121.             $posts[] = $this->buildPost(
  122.                 'mission',
  123.                 $sourceId,
  124.                 (string) ($row['username'] ?? 'Utilisateur'),
  125.                 (string) ($row['title'] ?? ''),
  126.                 (string) ($row['description'] ?? ''),
  127.                 $row['createdAt'] ?? null,
  128.                 '/missions/entraide/' $sourceId,
  129.                 $this->resolveProfileUrl($row),
  130.                 $this->resolveAvatarUrl($row),
  131.                 '%s a besoin d aide pour "%s".',
  132.                 (string) ($row['city'] ?? ''),
  133.                 !$isJoined && $archiveBadge === null,
  134.                 'creation',
  135.                 $isJoined,
  136.                 $archiveBadge,
  137.                 null,
  138.                 $row['endAt'] ?? null
  139.             );
  140.         }
  141.         $groupRows $entityManager->createQueryBuilder()
  142.             ->select('g.id AS id, g.title AS title, g.description AS description, g.city AS city, g.createdAt AS createdAt, u.id AS userId, u.username AS username, u.image AS userImage')
  143.             ->from(ThematicGroup::class, 'g')
  144.             ->innerJoin('g.owner''u')
  145.             ->orderBy('g.createdAt''DESC')
  146.             ->setMaxResults($limitPerType)
  147.             ->getQuery()
  148.             ->getArrayResult();
  149.         foreach ($groupRows as $row) {
  150.             $sourceId = (int) $row['id'];
  151.             $isJoined in_array($sourceId$joinedPosts['group'], true);
  152.             $posts[] = $this->buildPost(
  153.                 'group',
  154.                 $sourceId,
  155.                 (string) ($row['username'] ?? 'Utilisateur'),
  156.                 (string) ($row['title'] ?? ''),
  157.                 (string) ($row['description'] ?? ''),
  158.                 $row['createdAt'] ?? null,
  159.                 '/groups/thematic/' $sourceId,
  160.                 $this->resolveProfileUrl($row),
  161.                 $this->resolveAvatarUrl($row),
  162.                 '%s lance le groupe "%s".',
  163.                 (string) ($row['city'] ?? ''),
  164.                 !$isJoined,
  165.                 'creation',
  166.                 $isJoined
  167.             );
  168.         }
  169.         $participationRows $entityManager->createQueryBuilder()
  170.             ->select('n.id AS id, n.message AS message, n.redirectUrl AS redirectUrl, n.dateCreation AS createdAt, n.content AS content, su.id AS userId, su.username AS username, su.image AS userImage')
  171.             ->from(Notification::class, 'n')
  172.             ->innerJoin('n.sourceUser''su')
  173.             ->where('n.type = :type')
  174.             ->andWhere('n.status != :deleted')
  175.             ->setParameter('type''Feed.Participation')
  176.             ->setParameter('deleted'Notification::STATUS_DELETED)
  177.             ->orderBy('n.dateCreation''DESC')
  178.             ->setMaxResults($limitPerType 3)
  179.             ->getQuery()
  180.             ->getArrayResult();
  181.         foreach ($participationRows as $row) {
  182.             $params $this->extractNotificationParams((string) ($row['content'] ?? ''));
  183.             $postType = (string) ($params['sourceType'] ?? 'feed');
  184.             $sourceId max(0, (int) ($params['sourceId'] ?? 0));
  185.             $sourceSnapshot $this->resolveFeedSourceSnapshot($entityManager$postType$sourceId$now);
  186.             if (!$sourceSnapshot['visible']) {
  187.                 continue;
  188.             }
  189.             $posts[] = [
  190.                 'id' => sprintf('participation-%d', (int) ($row['id'] ?? 0)),
  191.                 'type' => $postType,
  192.                 'sourceId' => $sourceId,
  193.                 'kind' => 'participation',
  194.                 'joinable' => false,
  195.                 'username' => (string) ($row['username'] ?? 'Utilisateur'),
  196.                 'title' => (string) ($params['title'] ?? ''),
  197.                 'description' => (string) ($params['description'] ?? ''),
  198.                 'message' => (string) ($row['message'] ?? ''),
  199.                 'link' => (string) ($params['link'] ?? $row['redirectUrl'] ?? '/'),
  200.                 'profileUrl' => $this->resolveProfileUrl($row),
  201.                 'avatarUrl' => $this->resolveAvatarUrl($row),
  202.                 'locationLabel' => $sourceSnapshot['locationLabel'],
  203.                 'archiveBadge' => $sourceSnapshot['archiveBadge'],
  204.                 'startAt' => $sourceSnapshot['startAt'],
  205.                 'endAt' => $sourceSnapshot['endAt'],
  206.                 'createdAt' => (($row['createdAt'] ?? null) instanceof \DateTimeInterface)
  207.                     ? $row['createdAt']->format(\DateTimeInterface::ATOM)
  208.                     : (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM),
  209.             ];
  210.         }
  211.         usort(
  212.             $posts,
  213.             static fn (array $a, array $b): int => strcmp((string) ($b['createdAt'] ?? ''), (string) ($a['createdAt'] ?? ''))
  214.         );
  215.         $locations $this->extractLocations($posts);
  216.         if ($selectedType !== 'all') {
  217.             $posts array_values(array_filter(
  218.                 $posts,
  219.                 static fn (array $post): bool => ($post['type'] ?? '') === $selectedType
  220.             ));
  221.         }
  222.         if ($selectedLocation !== '') {
  223.             $posts array_values(array_filter(
  224.                 $posts,
  225.                 fn (array $post): bool => $this->normalizeLocation((string) ($post['locationLabel'] ?? '')) === $selectedLocation
  226.             ));
  227.         }
  228.         $total count($posts);
  229.         $slice array_slice($posts$offset$perPage);
  230.         $hasMore = ($offset $perPage) < $total;
  231.         return $this->json([
  232.             'posts' => array_values($slice),
  233.             'page' => $page,
  234.             'perPage' => $perPage,
  235.             'total' => $total,
  236.             'hasMore' => $hasMore,
  237.             'type' => $selectedType,
  238.             'location' => $selectedLocation,
  239.             'locations' => $locations,
  240.         ]);
  241.     }
  242.     private function buildPost(
  243.         string $type,
  244.         int $id,
  245.         string $username,
  246.         string $title,
  247.         string $description,
  248.         mixed $createdAt,
  249.         string $link,
  250.         string $profileUrl,
  251.         string $avatarUrl,
  252.         string $messageTemplate,
  253.         string $locationLabel,
  254.         bool $joinable,
  255.         string $kind,
  256.         bool $joined false,
  257.         ?array $archiveBadge null,
  258.         mixed $startAt null,
  259.         mixed $endAt null
  260.     ): array {
  261.         $safeTitle trim($title) !== '' trim($title) : 'contenu';
  262.         $created $createdAt instanceof \DateTimeInterface $createdAt : new \DateTimeImmutable();
  263.         return [
  264.             'id' => sprintf('%s-%d'$type$id),
  265.             'type' => $type,
  266.             'sourceId' => $id,
  267.             'kind' => $kind,
  268.             'joinable' => $joinable,
  269.             'joined' => $joined,
  270.             'username' => $username,
  271.             'title' => $safeTitle,
  272.             'description' => $description,
  273.             'message' => sprintf($messageTemplate$username$safeTitle),
  274.             'link' => $link,
  275.             'profileUrl' => $profileUrl,
  276.             'avatarUrl' => $avatarUrl,
  277.             'locationLabel' => trim($locationLabel),
  278.             'archiveBadge' => $archiveBadge,
  279.             'startAt' => $startAt instanceof \DateTimeInterface $startAt->format(\DateTimeInterface::ATOM) : null,
  280.             'endAt' => $endAt instanceof \DateTimeInterface $endAt->format(\DateTimeInterface::ATOM) : null,
  281.             'createdAt' => $created->format(\DateTimeInterface::ATOM),
  282.         ];
  283.     }
  284.     /**
  285.      * @param array<int, array<string, mixed>> $posts
  286.      * @return array<int, string>
  287.      */
  288.     private function extractLocations(array $posts): array
  289.     {
  290.         $labels = [];
  291.         foreach ($posts as $post) {
  292.             $location trim((string) ($post['locationLabel'] ?? ''));
  293.             if ($location === '') {
  294.                 continue;
  295.             }
  296.             $labels[$this->normalizeLocation($location)] = $location;
  297.         }
  298.         natcasesort($labels);
  299.         return array_values($labels);
  300.     }
  301.     private function normalizeLocation(string $value): string
  302.     {
  303.         $normalized trim(mb_strtolower($value));
  304.         $normalized preg_replace('/\s+/'' '$normalized) ?? $normalized;
  305.         return $normalized;
  306.     }
  307.     private function resolvePostLocation(EntityManagerInterface $entityManagerstring $postTypeint $sourceId): string
  308.     {
  309.         if ($sourceId <= 0) {
  310.             return '';
  311.         }
  312.         return match ($postType) {
  313.             'activity' => $entityManager->getRepository(InterestActivity::class)->find($sourceId)?->getCity() ?? '',
  314.             'group' => $entityManager->getRepository(ThematicGroup::class)->find($sourceId)?->getCity() ?? '',
  315.             'mission' => $entityManager->getRepository(HelpMission::class)->find($sourceId)?->getCity() ?? '',
  316.             default => '',
  317.         };
  318.     }
  319.     /**
  320.      * @return array{visible:bool, locationLabel:string, archiveBadge:?array{kind:string}, startAt:?string, endAt:?string}
  321.      */
  322.     private function resolveFeedSourceSnapshot(
  323.         EntityManagerInterface $entityManager,
  324.         string $postType,
  325.         int $sourceId,
  326.         \DateTimeImmutable $now
  327.     ): array {
  328.         if ($sourceId <= 0) {
  329.             return [
  330.                 'visible' => false,
  331.                 'locationLabel' => '',
  332.                 'archiveBadge' => null,
  333.                 'startAt' => null,
  334.                 'endAt' => null,
  335.             ];
  336.         }
  337.         $archiveThreshold ContentLifecycleManager::archiveThreshold($now);
  338.         return match ($postType) {
  339.             'activity' => $this->resolveEntityFeedSnapshot(
  340.                 $entityManager->getRepository(InterestActivity::class)->find($sourceId),
  341.                 fn (InterestActivity $activity): bool => ($activity->getStatus() === InterestActivity::STATUS_OPEN && ($activity->getEndAt() === null || $activity->getEndAt() > $now))
  342.                     || (in_array($activity->getStatus(), [InterestActivity::STATUS_CLOSEDInterestActivity::STATUS_CANCELED], true)
  343.                         && ($activity->getUpdatedAt() ?? $activity->getEndAt() ?? $activity->getCreatedAt()) > $archiveThreshold),
  344.                 fn (InterestActivity $activity): string => $activity->getCity(),
  345.                 fn (InterestActivity $activity): ?array => $this->resolveArchiveBadge($activity->getStatus(), $activity->getEndAt(), $now),
  346.                 fn (InterestActivity $activity): ?\DateTimeInterface => $activity->getStartAt(),
  347.                 fn (InterestActivity $activity): ?\DateTimeInterface => $activity->getEndAt()
  348.             ),
  349.             'mission' => $this->resolveEntityFeedSnapshot(
  350.                 $entityManager->getRepository(HelpMission::class)->find($sourceId),
  351.                 fn (HelpMission $mission): bool => (in_array($mission->getStatus(), [HelpMission::STATUS_OPENHelpMission::STATUS_IN_PROGRESS], true) && ($mission->getEndAt() === null || $mission->getEndAt() > $now))
  352.                     || ($mission->getStatus() === HelpMission::STATUS_DONE
  353.                         && ($mission->getUpdatedAt() ?? $mission->getEndAt() ?? $mission->getCreatedAt()) > $archiveThreshold),
  354.                 fn (HelpMission $mission): string => $mission->getCity(),
  355.                 fn (HelpMission $mission): ?array => $this->resolveArchiveBadge($mission->getStatus(), $mission->getEndAt(), $now),
  356.                 static fn (): ?\DateTimeInterface => null,
  357.                 fn (HelpMission $mission): ?\DateTimeInterface => $mission->getEndAt()
  358.             ),
  359.             'group' => $this->resolveEntityFeedSnapshot(
  360.                 $entityManager->getRepository(ThematicGroup::class)->find($sourceId),
  361.                 static fn (?ThematicGroup $group): bool => $group instanceof ThematicGroup,
  362.                 fn (ThematicGroup $group): string => $group->getCity(),
  363.                 static fn (): ?array => null,
  364.                 static fn (): ?\DateTimeInterface => null,
  365.                 static fn (): ?\DateTimeInterface => null
  366.             ),
  367.             default => [
  368.                 'visible' => false,
  369.                 'locationLabel' => '',
  370.                 'archiveBadge' => null,
  371.                 'startAt' => null,
  372.                 'endAt' => null,
  373.             ],
  374.         };
  375.     }
  376.     /**
  377.      * @template T of object
  378.      * @param T|null $entity
  379.      * @param callable(T|null):bool $visibilityResolver
  380.      * @param callable(T):string $locationResolver
  381.      * @param callable(T):(?array{kind:string}) $badgeResolver
  382.      * @param callable(T):(?\DateTimeInterface) $startAtResolver
  383.      * @param callable(T):(?\DateTimeInterface) $endAtResolver
  384.      * @return array{visible:bool, locationLabel:string, archiveBadge:?array{kind:string}, startAt:?string, endAt:?string}
  385.      */
  386.     private function resolveEntityFeedSnapshot(
  387.         ?object $entity,
  388.         callable $visibilityResolver,
  389.         callable $locationResolver,
  390.         callable $badgeResolver,
  391.         callable $startAtResolver,
  392.         callable $endAtResolver
  393.     ): array {
  394.         if (!$visibilityResolver($entity)) {
  395.             return [
  396.                 'visible' => false,
  397.                 'locationLabel' => '',
  398.                 'archiveBadge' => null,
  399.                 'startAt' => null,
  400.                 'endAt' => null,
  401.             ];
  402.         }
  403.         return [
  404.             'visible' => true,
  405.             'locationLabel' => trim((string) $locationResolver($entity)),
  406.             'archiveBadge' => $badgeResolver($entity),
  407.             'startAt' => ($startAtResolver($entity) instanceof \DateTimeInterface) ? $startAtResolver($entity)->format(\DateTimeInterface::ATOM) : null,
  408.             'endAt' => ($endAtResolver($entity) instanceof \DateTimeInterface) ? $endAtResolver($entity)->format(\DateTimeInterface::ATOM) : null,
  409.         ];
  410.     }
  411.     /**
  412.      * @return array{kind:string}|null
  413.      */
  414.     private function resolveArchiveBadge(string $statusmixed $endAt\DateTimeImmutable $now): ?array
  415.     {
  416.         if ($endAt instanceof \DateTimeInterface && $endAt <= $now) {
  417.             return [
  418.                 'kind' => 'expired',
  419.             ];
  420.         }
  421.         if (in_array($status, [InterestActivity::STATUS_CLOSEDInterestActivity::STATUS_CANCELEDHelpMission::STATUS_DONE], true)) {
  422.             return [
  423.                 'kind' => 'closed',
  424.             ];
  425.         }
  426.         return null;
  427.     }
  428.     /**
  429.      * @param array<string, mixed> $row
  430.      */
  431.     private function resolveProfileUrl(array $row): string
  432.     {
  433.         $userId = (int) ($row['userId'] ?? 0);
  434.         if ($userId <= 0) {
  435.             return '/user/profil';
  436.         }
  437.         return '/user/' $userId;
  438.     }
  439.     /**
  440.      * @param array<string, mixed> $row
  441.      */
  442.     private function resolveAvatarUrl(array $row): string
  443.     {
  444.         $raw trim((string) ($row['userImage'] ?? ''));
  445.         if ($raw === '') {
  446.             return '/officials-imgs/logo.webp';
  447.         }
  448.         if (str_starts_with($raw'/var/')) {
  449.             return '/officials-imgs/logo.webp';
  450.         }
  451.         if (str_starts_with($raw'/public/')) {
  452.             return substr($raw7);
  453.         }
  454.         if (str_starts_with($raw'http://') || str_starts_with($raw'https://') || str_starts_with($raw'/')) {
  455.             return $raw;
  456.         }
  457.         return '/' ltrim($raw'/');
  458.     }
  459.     /**
  460.      * @return array<string, mixed>
  461.      */
  462.     private function extractNotificationParams(string $content): array
  463.     {
  464.         if ($content === '') {
  465.             return [];
  466.         }
  467.         try {
  468.             $decoded json_decode($contenttrue512JSON_THROW_ON_ERROR);
  469.             if (!is_array($decoded)) {
  470.                 return [];
  471.             }
  472.             $first $decoded[0] ?? null;
  473.             if (!is_array($first)) {
  474.                 return [];
  475.             }
  476.             $params $first['params'] ?? [];
  477.             return is_array($params) ? $params : [];
  478.         } catch (\Throwable) {
  479.             return [];
  480.         }
  481.     }
  482.     #[Route('/api/home/feed/participate/{sourceType}/{sourceId}'name'app_home_feed_participate'methods: ['POST'])]
  483.     public function participate(
  484.         string $sourceType,
  485.         int $sourceId,
  486.         EntityManagerInterface $entityManager,
  487.         EventNotificationManager $eventNotificationManager,
  488.         AgendaReminderService $agendaReminderService,
  489.         ContentConversationManager $contentConversationManager,
  490.         InterestActivityRepository $interestActivityRepository,
  491.         InterestActivityRegistrationRepository $interestActivityRegistrationRepository,
  492.         ThematicGroupRepository $thematicGroupRepository,
  493.         HelpMissionRepository $helpMissionRepository
  494.     ): JsonResponse {
  495.         $this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
  496.         /** @var User $user */
  497.         $user $this->getUser();
  498.         $normalizedType strtolower(trim($sourceType));
  499.         if (!in_array($normalizedType, ['activity''mission''group'], true)) {
  500.             throw new BadRequestHttpException('Type de contenu invalide.');
  501.         }
  502.         if ($sourceId <= 0) {
  503.             throw new BadRequestHttpException('Identifiant invalide.');
  504.         }
  505.         $now = new \DateTimeImmutable();
  506.         $message '';
  507.         $description '';
  508.         $title '';
  509.         $link '/';
  510.         if ($normalizedType === 'activity') {
  511.             /** @var InterestActivity|null $activity */
  512.             $activity $entityManager->getRepository(InterestActivity::class)->find($sourceId);
  513.             if (!$activity) {
  514.                 return $this->json(['ok' => false'message' => 'Activite introuvable.'], 404);
  515.             }
  516.             if ($activity->getOrganizer() && $activity->getOrganizer()->getId() === $user->getId()) {
  517.                 return $this->json(['ok' => false'message' => 'Vous etes deja organisateur de cette activite.'], 409);
  518.             }
  519.             if ($activity->getStatus() !== InterestActivity::STATUS_OPEN) {
  520.                 return $this->json(['ok' => false'message' => 'Cette activite n accepte plus de participations.'], 409);
  521.             }
  522.             if ($activity->getStartAt() && $activity->getStartAt() < $now->modify('-2 hours')) {
  523.                 return $this->json(['ok' => false'message' => 'La participation est fermee pour cette activite.'], 409);
  524.             }
  525.             $existing $interestActivityRegistrationRepository->findOneBy([
  526.                 'activity' => $activity,
  527.                 'user' => $user,
  528.             ]);
  529.             if ($existing && in_array($existing->getStatus(), [
  530.                 InterestActivityRegistration::STATUS_REGISTERED,
  531.                 InterestActivityRegistration::STATUS_PARTICIPATED,
  532.             ], true)) {
  533.                 return $this->json(['ok' => true'already' => true'message' => 'Vous participez deja a cette activite.']);
  534.             }
  535.             if ($interestActivityRepository->countRegisteredSpots($activity) >= $activity->getCapacity()) {
  536.                 return $this->json(['ok' => false'message' => 'Cette activite est complete.'], 409);
  537.             }
  538.             if (!$existing) {
  539.                 $existing = (new InterestActivityRegistration())
  540.                     ->setActivity($activity)
  541.                     ->setUser($user)
  542.                     ->setCreatedAt($now);
  543.             }
  544.             $existing
  545.                 ->setStatus(InterestActivityRegistration::STATUS_REGISTERED)
  546.                 ->setUpdatedAt($now);
  547.             $entityManager->persist($existing);
  548.             $entityManager->flush();
  549.             $contentConversationManager->ensureParticipant(
  550.                 $contentConversationManager->ensureForInterestActivity($activity),
  551.                 $user
  552.             );
  553.             $title $activity->getTitle();
  554.             $description $activity->getDescription();
  555.             $link '/activities/interet/' $activity->getId();
  556.             $message sprintf('%s participe a l activite "%s".'$user->getUsername(), $title);
  557.         } elseif ($normalizedType === 'mission') {
  558.             /** @var HelpMission|null $mission */
  559.             $mission $helpMissionRepository->find($sourceId);
  560.             if (!$mission) {
  561.                 return $this->json(['ok' => false'message' => 'Mission introuvable.'], 404);
  562.             }
  563.             if ($mission->getCreator() && $mission->getCreator()->getId() === $user->getId()) {
  564.                 return $this->json(['ok' => false'message' => 'Vous ne pouvez pas repondre a votre propre mission.'], 409);
  565.             }
  566.             if ($mission->getResponder() && $mission->getResponder()->getId() === $user->getId()) {
  567.                 return $this->json(['ok' => true'already' => true'message' => 'Vous participez deja a cette entraide.']);
  568.             }
  569.             if ($mission->getStatus() === HelpMission::STATUS_OPEN) {
  570.                 if ($mission->getResponder() && $mission->getResponder()->getId() !== $user->getId()) {
  571.                     return $this->json(['ok' => false'message' => 'Cette mission est deja prise en charge.'], 409);
  572.                 }
  573.                 $mission
  574.                     ->setResponder($user)
  575.                     ->setStatus(HelpMission::STATUS_IN_PROGRESS)
  576.                     ->setUpdatedAt($now);
  577.                 $entityManager->persist($mission);
  578.                 $entityManager->flush();
  579.                 $contentConversationManager->ensureParticipant(
  580.                     $contentConversationManager->ensureForHelpMission($mission),
  581.                     $user
  582.                 );
  583.             } else {
  584.                 return $this->json(['ok' => false'message' => 'Cette mission ne peut plus etre rejointe.'], 409);
  585.             }
  586.             $title $mission->getTitle();
  587.             $description $mission->getDescription();
  588.             $link '/missions/entraide/' $mission->getId();
  589.             $beneficiary $mission->getCreator()?->getUsername() ?? 'cet utilisateur';
  590.             $message sprintf('%s a accepte d aider %s pour "%s".'$user->getUsername(), $beneficiary$title);
  591.         } else {
  592.             /** @var ThematicGroup|null $group */
  593.             $group $thematicGroupRepository->find($sourceId);
  594.             if (!$group) {
  595.                 return $this->json(['ok' => false'message' => 'Groupe introuvable.'], 404);
  596.             }
  597.             /** @var ThematicGroupMember|null $existing */
  598.             $existing $entityManager->getRepository(ThematicGroupMember::class)->findOneBy([
  599.                 'thematicGroup' => $group,
  600.                 'user' => $user,
  601.             ]);
  602.             if ($existing && $existing->getStatus() === ThematicGroupMember::STATUS_ACTIVE) {
  603.                 return $this->json(['ok' => true'already' => true'message' => 'Vous participez deja a ce groupe.']);
  604.             }
  605.             if ($existing && $existing->getStatus() === ThematicGroupMember::STATUS_BANNED) {
  606.                 return $this->json(['ok' => false'message' => 'Votre acces a ce groupe est bloque.'], 403);
  607.             }
  608.             if ($group->getStatus() !== ThematicGroup::STATUS_OPEN) {
  609.                 return $this->json(['ok' => false'message' => 'Ce groupe n accepte plus de participations.'], 409);
  610.             }
  611.             if ($thematicGroupRepository->countActiveMembers($group) >= $group->getCapacity()) {
  612.                 return $this->json(['ok' => false'message' => 'Ce groupe a atteint sa capacite maximale.'], 409);
  613.             }
  614.             if (!$existing) {
  615.                 $existing = (new ThematicGroupMember())
  616.                     ->setThematicGroup($group)
  617.                     ->setUser($user)
  618.                     ->setRole(ThematicGroupMember::ROLE_MEMBER)
  619.                     ->setCreatedAt($now);
  620.             }
  621.             $existing
  622.                 ->setStatus(ThematicGroupMember::STATUS_ACTIVE)
  623.                 ->setUpdatedAt($now);
  624.             $entityManager->persist($existing);
  625.             $entityManager->flush();
  626.             $contentConversationManager->ensureParticipant(
  627.                 $contentConversationManager->ensureForThematicGroup($group),
  628.                 $user
  629.             );
  630.             $title $group->getTitle();
  631.             $description $group->getDescription();
  632.             $link '/groups/thematic/' $group->getId();
  633.             $message sprintf('%s rejoint la discussion "%s".'$user->getUsername(), $title);
  634.         }
  635.         if ($message !== '') {
  636.             $eventNotificationManager->notify(
  637.                 $user,
  638.                 [$user],
  639.                 'Feed.Participation',
  640.                 $message,
  641.                 $link,
  642.                 [
  643.                     'sourceType' => $normalizedType,
  644.                     'sourceId' => $sourceId,
  645.                     'title' => $title,
  646.                     'description' => $description,
  647.                     'link' => $link,
  648.                     'action' => 'participate',
  649.                 ],
  650.                 true
  651.             );
  652.             // Répercute immédiatement la participation dans l'agenda utilisateur.
  653.             $agendaReminderService->syncRemindersForUser($user);
  654.         }
  655.         return $this->json([
  656.             'ok' => true,
  657.             'message' => $message,
  658.         ]);
  659.     }
  660.     private function hasParticipationPost(
  661.         EntityManagerInterface $entityManager,
  662.         User $user,
  663.         string $sourceType,
  664.         int $sourceId
  665.     ): bool {
  666.         $needleType sprintf('"sourceType":"%s"'addslashes($sourceType));
  667.         $needleId sprintf('"sourceId":%d'$sourceId);
  668.         $count = (int) $entityManager->createQueryBuilder()
  669.             ->select('COUNT(n.id)')
  670.             ->from(Notification::class, 'n')
  671.             ->where('n.type = :type')
  672.             ->andWhere('n.sourceUser = :user')
  673.             ->andWhere('n.status != :deleted')
  674.             ->andWhere('n.content LIKE :needleType')
  675.             ->andWhere('n.content LIKE :needleId')
  676.             ->setParameter('type''Feed.Participation')
  677.             ->setParameter('user'$user)
  678.             ->setParameter('deleted'Notification::STATUS_DELETED)
  679.             ->setParameter('needleType''%' $needleType '%')
  680.             ->setParameter('needleId''%' $needleId '%')
  681.             ->getQuery()
  682.             ->getSingleScalarResult();
  683.         return $count 0;
  684.     }
  685.     /**
  686.      * @return array{activity:list<int>, mission:list<int>, group:list<int>}
  687.      */
  688.     private function buildJoinedPostIndex(EntityManagerInterface $entityManager, ?User $user): array
  689.     {
  690.         $index = [
  691.             'activity' => [],
  692.             'mission' => [],
  693.             'group' => [],
  694.         ];
  695.         if (!$user) {
  696.             return $index;
  697.         }
  698.         $index['activity'] = array_values(array_unique(array_map('intval'array_column(
  699.             $entityManager->createQueryBuilder()
  700.                 ->select('IDENTITY(r.activity) AS sourceId')
  701.                 ->from(InterestActivityRegistration::class, 'r')
  702.                 ->where('r.user = :user')
  703.                 ->andWhere('r.status IN (:statuses)')
  704.                 ->setParameter('user'$user)
  705.                 ->setParameter('statuses', [
  706.                     InterestActivityRegistration::STATUS_REGISTERED,
  707.                     InterestActivityRegistration::STATUS_PARTICIPATED,
  708.                 ])
  709.                 ->getQuery()
  710.                 ->getArrayResult(),
  711.             'sourceId'
  712.         ))));
  713.         $index['mission'] = array_values(array_unique(array_map('intval'array_column(
  714.             $entityManager->createQueryBuilder()
  715.                 ->select('m.id AS sourceId')
  716.                 ->from(HelpMission::class, 'm')
  717.                 ->where('m.responder = :user')
  718.                 ->setParameter('user'$user)
  719.                 ->getQuery()
  720.                 ->getArrayResult(),
  721.             'sourceId'
  722.         ))));
  723.         $index['group'] = array_values(array_unique(array_map('intval'array_column(
  724.             $entityManager->createQueryBuilder()
  725.                 ->select('IDENTITY(m.thematicGroup) AS sourceId')
  726.                 ->from(ThematicGroupMember::class, 'm')
  727.                 ->where('m.user = :user')
  728.                 ->andWhere('m.status = :status')
  729.                 ->setParameter('user'$user)
  730.                 ->setParameter('status'ThematicGroupMember::STATUS_ACTIVE)
  731.                 ->getQuery()
  732.                 ->getArrayResult(),
  733.             'sourceId'
  734.         ))));
  735.         return $index;
  736.     }
  737. }