src/Controller/TrashTalkController.php line 65

Open in your IDE?
  1. <?php
  2. namespace App\Controller;
  3. use App\Entity\TrashTalk;
  4. use App\Entity\User;
  5. use App\Repository\TrashTalkRepository;
  6. use App\Repository\SeasonRepository;
  7. use App\Repository\UserTeamRepository;
  8. use App\Service\GoogleChatNotifierService;
  9. use App\Service\GoogleChat\TrashTalkMessageGenerator;
  10. use Doctrine\ORM\EntityManagerInterface;
  11. use Psr\Log\LoggerInterface;
  12. use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
  13. use Symfony\Component\HttpFoundation\Request;
  14. use Symfony\Component\HttpFoundation\Response;
  15. use Symfony\Component\Routing\Annotation\Route;
  16. use Symfony\Component\Security\Http\Attribute\IsGranted;
  17. use Symfony\Contracts\HttpClient\HttpClientInterface;
  18. #[Route('/api/trash-talk')]
  19. #[IsGranted('ROLE_USER')]
  20. class TrashTalkController extends AbstractController
  21. {
  22.     public function __construct(
  23.         private TrashTalkRepository $trashTalkRepository,
  24.         private SeasonRepository $seasonRepository,
  25.         private UserTeamRepository $userTeamRepository,
  26.         private EntityManagerInterface $entityManager,
  27.         private TrashTalkMessageGenerator $trashTalkMessageGenerator,
  28.         private HttpClientInterface $httpClient,
  29.         private LoggerInterface $logger
  30.     ) {}
  31.     #[Route('/season/{seasonId}'name'api_trash_talk_list'methods: ['GET'])]
  32.     public function list(int $seasonIdRequest $request): Response
  33.     {
  34.         $season $this->seasonRepository->find($seasonId);
  35.         if (!$season) {
  36.             return $this->json(['error' => 'Season not found'], Response::HTTP_NOT_FOUND);
  37.         }
  38.         /** @var User $user */
  39.         $user $this->getUser();
  40.         // Check if user is on a team in this season
  41.         if (!$this->isUserInSeasonTeam($user$season)) {
  42.             return $this->json(['error' => 'You must be on a team in this season to view trash talk'], Response::HTTP_FORBIDDEN);
  43.         }
  44.         $limit $request->query->getInt('limit'10);
  45.         $offset $request->query->getInt('offset'0);
  46.         $posts $this->trashTalkRepository->findBySeason($season$limit$offset);
  47.         $total $this->trashTalkRepository->countBySeason($season);
  48.         return $this->json([
  49.             'posts' => $this->serializePosts($posts),
  50.             'total' => $total,
  51.             'hasMore' => ($offset $limit) < $total
  52.         ]);
  53.     }
  54.     #[Route('/season/{seasonId}/new'name'api_trash_talk_new'methods: ['GET'])]
  55.     public function getNew(int $seasonIdRequest $request): Response
  56.     {
  57.         $season $this->seasonRepository->find($seasonId);
  58.         if (!$season) {
  59.             return $this->json(['error' => 'Season not found'], Response::HTTP_NOT_FOUND);
  60.         }
  61.         /** @var User $user */
  62.         $user $this->getUser();
  63.         // Check if user is on a team in this season
  64.         if (!$this->isUserInSeasonTeam($user$season)) {
  65.             return $this->json(['error' => 'You must be on a team in this season to view trash talk'], Response::HTTP_FORBIDDEN);
  66.         }
  67.         $since $request->query->get('since');
  68.         if (!$since) {
  69.             return $this->json(['error' => 'Missing "since" parameter'], Response::HTTP_BAD_REQUEST);
  70.         }
  71.         try {
  72.             $after = new \DateTimeImmutable($since);
  73.         } catch (\Exception $e) {
  74.             return $this->json(['error' => 'Invalid date format'], Response::HTTP_BAD_REQUEST);
  75.         }
  76.         $posts $this->trashTalkRepository->findNewPosts($season$after);
  77.         return $this->json([
  78.             'posts' => $this->serializePosts($posts)
  79.         ]);
  80.     }
  81.     #[Route('/season/{seasonId}'name'api_trash_talk_create'methods: ['POST'])]
  82.     public function create(int $seasonIdRequest $request): Response
  83.     {
  84.         $season $this->seasonRepository->find($seasonId);
  85.         if (!$season) {
  86.             return $this->json(['error' => 'Season not found'], Response::HTTP_NOT_FOUND);
  87.         }
  88.         /** @var User $user */
  89.         $user $this->getUser();
  90.         // Check if user is on a team in this season
  91.         if (!$this->isUserInSeasonTeam($user$season)) {
  92.             return $this->json(['error' => 'You must be on a team in this season to post trash talk'], Response::HTTP_FORBIDDEN);
  93.         }
  94.         $data json_decode($request->getContent(), true);
  95.         $content $data['content'] ?? null;
  96.         if (!$content || trim($content) === '') {
  97.             return $this->json(['error' => 'Content cannot be empty'], Response::HTTP_BAD_REQUEST);
  98.         }
  99.         // Trim and limit content length
  100.         $content trim($content);
  101.         if (strlen($content) > 500) {
  102.             return $this->json(['error' => 'Content cannot exceed 500 characters'], Response::HTTP_BAD_REQUEST);
  103.         }
  104.         $post = new TrashTalk();
  105.         $post->setSeason($season);
  106.         $post->setUser($user);
  107.         $post->setContent($content);
  108.         $this->entityManager->persist($post);
  109.         $this->entityManager->flush();
  110.         // Send Google Chat notification
  111.         $this->sendGoogleChatNotification($post);
  112.         return $this->json([
  113.             'post' => $this->serializePost($post)
  114.         ], Response::HTTP_CREATED);
  115.     }
  116.     #[Route('/{id}'name'api_trash_talk_update'methods: ['PUT'])]
  117.     public function update(int $idRequest $request): Response
  118.     {
  119.         $post $this->trashTalkRepository->find($id);
  120.         if (!$post) {
  121.             return $this->json(['error' => 'Post not found'], Response::HTTP_NOT_FOUND);
  122.         }
  123.         /** @var User $user */
  124.         $user $this->getUser();
  125.         // Only the author can edit their post
  126.         if ($post->getUser() !== $user) {
  127.             return $this->json(['error' => 'You can only edit your own posts'], Response::HTTP_FORBIDDEN);
  128.         }
  129.         // Check if post is deleted
  130.         if ($post->isIsDeleted()) {
  131.             return $this->json(['error' => 'Cannot edit a deleted post'], Response::HTTP_FORBIDDEN);
  132.         }
  133.         $data json_decode($request->getContent(), true);
  134.         $content $data['content'] ?? null;
  135.         if (!$content || trim($content) === '') {
  136.             return $this->json(['error' => 'Content cannot be empty'], Response::HTTP_BAD_REQUEST);
  137.         }
  138.         // Trim and limit content length
  139.         $content trim($content);
  140.         if (strlen($content) > 500) {
  141.             return $this->json(['error' => 'Content cannot exceed 500 characters'], Response::HTTP_BAD_REQUEST);
  142.         }
  143.         $post->setContent($content);
  144.         $post->setUpdatedAt(new \DateTimeImmutable());
  145.         $this->entityManager->flush();
  146.         return $this->json([
  147.             'post' => $this->serializePost($post)
  148.         ]);
  149.     }
  150.     #[Route('/{id}'name'api_trash_talk_delete'methods: ['DELETE'])]
  151.     public function delete(int $id): Response
  152.     {
  153.         $post $this->trashTalkRepository->find($id);
  154.         if (!$post) {
  155.             return $this->json(['error' => 'Post not found'], Response::HTTP_NOT_FOUND);
  156.         }
  157.         /** @var User $user */
  158.         $user $this->getUser();
  159.         // Check if user can delete this post
  160.         if (!$this->canUserDeletePost($user$post)) {
  161.             return $this->json(['error' => 'You do not have permission to delete this post'], Response::HTTP_FORBIDDEN);
  162.         }
  163.         // Soft delete
  164.         $post->setIsDeleted(true);
  165.         $this->entityManager->flush();
  166.         return $this->json([
  167.             'success' => true,
  168.             'post' => $this->serializePost($post)
  169.         ]);
  170.     }
  171.     /**
  172.      * Check if user is on a team in the given season
  173.      */
  174.     private function isUserInSeasonTeam(User $user$season): bool
  175.     {
  176.         $userTeams $this->userTeamRepository->findBy(['user' => $user]);
  177.         foreach ($userTeams as $userTeam) {
  178.             $team $userTeam->getTeam();
  179.             $teamSeasons $team->getTeamSeasons();
  180.             foreach ($teamSeasons as $teamSeason) {
  181.                 if ($teamSeason->getSeason() === $season) {
  182.                     return true;
  183.                 }
  184.             }
  185.         }
  186.         return false;
  187.     }
  188.     /**
  189.      * Check if user can delete a post
  190.      * User can delete if:
  191.      * - They are the author
  192.      * - They are a team captain of a team in the season
  193.      * - They have ROLE_ADMIN
  194.      */
  195.     private function canUserDeletePost(User $userTrashTalk $post): bool
  196.     {
  197.         // Admin can delete any post
  198.         if ($this->isGranted('ROLE_ADMIN')) {
  199.             return true;
  200.         }
  201.         // Author can delete their own post
  202.         if ($post->getUser() === $user) {
  203.             return true;
  204.         }
  205.         // Team captain can delete posts
  206.         $userTeams $this->userTeamRepository->findBy(['user' => $user]);
  207.         foreach ($userTeams as $userTeam) {
  208.             if ($userTeam->getRole() === 'captain') {
  209.                 $team $userTeam->getTeam();
  210.                 $teamSeasons $team->getTeamSeasons();
  211.                 foreach ($teamSeasons as $teamSeason) {
  212.                     if ($teamSeason->getSeason() === $post->getSeason()) {
  213.                         return true;
  214.                     }
  215.                 }
  216.             }
  217.         }
  218.         return false;
  219.     }
  220.     /**
  221.      * Serialize a single post
  222.      */
  223.     private function serializePost(TrashTalk $post): array
  224.     {
  225.         $user $post->getUser();
  226.         $userTeam $user->getUserTeam();
  227.         $profile $user->getProfile();
  228.         return [
  229.             'id' => $post->getId(),
  230.             'content' => $post->getContent(),
  231.             'createdAt' => $post->getCreatedAt()->format('c'),
  232.             'updatedAt' => $post->getUpdatedAt()?->format('c'),
  233.             'isDeleted' => $post->isIsDeleted(),
  234.             'user' => [
  235.                 'id' => $user->getId(),
  236.                 'firstName' => $user->getFirstName(),
  237.                 'lastName' => $user->getLastName(),
  238.                 'fullName' => $user->getFullName(),
  239.                 'avatar' => $profile?->getAvatar(),
  240.                 'team' => $userTeam ? [
  241.                     'id' => $userTeam->getTeam()->getId(),
  242.                     'name' => $userTeam->getTeam()->getName()
  243.                 ] : null
  244.             ]
  245.         ];
  246.     }
  247.     /**
  248.      * Serialize multiple posts
  249.      */
  250.     private function serializePosts(array $posts): array
  251.     {
  252.         return array_map(fn($post) => $this->serializePost($post), $posts);
  253.     }
  254.     /**
  255.      * Send Google Chat notification for a trash talk post
  256.      */
  257.     private function sendGoogleChatNotification(TrashTalk $post): void
  258.     {
  259.         $season $post->getSeason();
  260.         $config $season->getGoogleChatConfig();
  261.         // Check if Google Chat is configured and enabled
  262.         if (!$config || !$config->isIsEnabled()) {
  263.             return;
  264.         }
  265.         // Check if trash_talk message type is enabled
  266.         if (!$config->isMessageTypeEnabled('trash_talk')) {
  267.             $this->logger->info('Trash talk notifications are disabled for this season', [
  268.                 'season' => $season->getName(),
  269.             ]);
  270.             return;
  271.         }
  272.         // Check if webhook URL is configured
  273.         if (!$config->getWebhookUrl()) {
  274.             $this->logger->warning('No webhook URL configured for Google Chat', [
  275.                 'season' => $season->getName(),
  276.             ]);
  277.             return;
  278.         }
  279.         // Rate limiting: Only send one notification every 30 minutes per season
  280.         $messageTypeConfig $config->getMessageTypeConfig('trash_talk');
  281.         if ($messageTypeConfig) {
  282.             $lastNotificationTime $messageTypeConfig->getConfigValue('last_notification_time');
  283.             if ($lastNotificationTime) {
  284.                 $lastTime = new \DateTime($lastNotificationTime);
  285.                 $now = new \DateTime();
  286.                 $diffInMinutes = ($now->getTimestamp() - $lastTime->getTimestamp()) / 60;
  287.                 if ($diffInMinutes 30) {
  288.                     $remainingMinutes ceil(30 $diffInMinutes);
  289.                     $this->logger->info('Trash talk notification rate limited', [
  290.                         'season' => $season->getName(),
  291.                         'remaining_minutes' => $remainingMinutes,
  292.                     ]);
  293.                     return;
  294.                 }
  295.             }
  296.         }
  297.         try {
  298.             // Try to send as a card first (richer format)
  299.             $cardPayload $this->trashTalkMessageGenerator->generateCardForPost($post);
  300.             $response $this->httpClient->request('POST'$config->getWebhookUrl(), [
  301.                 'json' => $cardPayload,
  302.             ]);
  303.             if ($response->getStatusCode() === 200) {
  304.                 $this->logger->info('Google Chat card notification sent for trash talk', [
  305.                     'season' => $season->getName(),
  306.                     'post_id' => $post->getId(),
  307.                     'user' => $post->getUser()->getFullName(),
  308.                 ]);
  309.                 // Update last notification time
  310.                 $this->updateLastNotificationTime($messageTypeConfig);
  311.                 return;
  312.             }
  313.         } catch (\Exception $e) {
  314.             // Card failed, fall back to simple text message
  315.             $this->logger->warning('Failed to send card, falling back to text message', [
  316.                 'error' => $e->getMessage(),
  317.             ]);
  318.         }
  319.         try {
  320.             // Fallback to simple text message
  321.             $message $this->trashTalkMessageGenerator->generateForPost($post);
  322.             $response $this->httpClient->request('POST'$config->getWebhookUrl(), [
  323.                 'json' => [
  324.                     'text' => $message,
  325.                 ],
  326.             ]);
  327.             if ($response->getStatusCode() === 200) {
  328.                 $this->logger->info('Google Chat text notification sent for trash talk', [
  329.                     'season' => $season->getName(),
  330.                     'post_id' => $post->getId(),
  331.                     'user' => $post->getUser()->getFullName(),
  332.                 ]);
  333.                 // Update last notification time
  334.                 $this->updateLastNotificationTime($messageTypeConfig);
  335.             }
  336.         } catch (\Exception $e) {
  337.             $this->logger->error('Failed to send Google Chat notification for trash talk', [
  338.                 'season' => $season->getName(),
  339.                 'post_id' => $post->getId(),
  340.                 'error' => $e->getMessage(),
  341.             ]);
  342.         }
  343.     }
  344.     /**
  345.      * Update the last notification time for rate limiting
  346.      */
  347.     private function updateLastNotificationTime($messageTypeConfig): void
  348.     {
  349.         if (!$messageTypeConfig) {
  350.             return;
  351.         }
  352.         try {
  353.             $now = new \DateTime();
  354.             $messageTypeConfig->setConfigValue('last_notification_time'$now->format('Y-m-d H:i:s'));
  355.             $this->entityManager->persist($messageTypeConfig);
  356.             $this->entityManager->flush();
  357.             $this->logger->debug('Updated last notification time for trash talk', [
  358.                 'time' => $now->format('Y-m-d H:i:s'),
  359.             ]);
  360.         } catch (\Exception $e) {
  361.             $this->logger->error('Failed to update last notification time', [
  362.                 'error' => $e->getMessage(),
  363.             ]);
  364.         }
  365.     }
  366. }