<?php
namespace App\Controller;
use App\Entity\TrashTalk;
use App\Entity\User;
use App\Repository\TrashTalkRepository;
use App\Repository\SeasonRepository;
use App\Repository\UserTeamRepository;
use App\Service\GoogleChatNotifierService;
use App\Service\GoogleChat\TrashTalkMessageGenerator;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Contracts\HttpClient\HttpClientInterface;
#[Route('/api/trash-talk')]
#[IsGranted('ROLE_USER')]
class TrashTalkController extends AbstractController
{
public function __construct(
private TrashTalkRepository $trashTalkRepository,
private SeasonRepository $seasonRepository,
private UserTeamRepository $userTeamRepository,
private EntityManagerInterface $entityManager,
private TrashTalkMessageGenerator $trashTalkMessageGenerator,
private HttpClientInterface $httpClient,
private LoggerInterface $logger
) {}
#[Route('/season/{seasonId}', name: 'api_trash_talk_list', methods: ['GET'])]
public function list(int $seasonId, Request $request): Response
{
$season = $this->seasonRepository->find($seasonId);
if (!$season) {
return $this->json(['error' => 'Season not found'], Response::HTTP_NOT_FOUND);
}
/** @var User $user */
$user = $this->getUser();
// Check if user is on a team in this season
if (!$this->isUserInSeasonTeam($user, $season)) {
return $this->json(['error' => 'You must be on a team in this season to view trash talk'], Response::HTTP_FORBIDDEN);
}
$limit = $request->query->getInt('limit', 10);
$offset = $request->query->getInt('offset', 0);
$posts = $this->trashTalkRepository->findBySeason($season, $limit, $offset);
$total = $this->trashTalkRepository->countBySeason($season);
return $this->json([
'posts' => $this->serializePosts($posts),
'total' => $total,
'hasMore' => ($offset + $limit) < $total
]);
}
#[Route('/season/{seasonId}/new', name: 'api_trash_talk_new', methods: ['GET'])]
public function getNew(int $seasonId, Request $request): Response
{
$season = $this->seasonRepository->find($seasonId);
if (!$season) {
return $this->json(['error' => 'Season not found'], Response::HTTP_NOT_FOUND);
}
/** @var User $user */
$user = $this->getUser();
// Check if user is on a team in this season
if (!$this->isUserInSeasonTeam($user, $season)) {
return $this->json(['error' => 'You must be on a team in this season to view trash talk'], Response::HTTP_FORBIDDEN);
}
$since = $request->query->get('since');
if (!$since) {
return $this->json(['error' => 'Missing "since" parameter'], Response::HTTP_BAD_REQUEST);
}
try {
$after = new \DateTimeImmutable($since);
} catch (\Exception $e) {
return $this->json(['error' => 'Invalid date format'], Response::HTTP_BAD_REQUEST);
}
$posts = $this->trashTalkRepository->findNewPosts($season, $after);
return $this->json([
'posts' => $this->serializePosts($posts)
]);
}
#[Route('/season/{seasonId}', name: 'api_trash_talk_create', methods: ['POST'])]
public function create(int $seasonId, Request $request): Response
{
$season = $this->seasonRepository->find($seasonId);
if (!$season) {
return $this->json(['error' => 'Season not found'], Response::HTTP_NOT_FOUND);
}
/** @var User $user */
$user = $this->getUser();
// Check if user is on a team in this season
if (!$this->isUserInSeasonTeam($user, $season)) {
return $this->json(['error' => 'You must be on a team in this season to post trash talk'], Response::HTTP_FORBIDDEN);
}
$data = json_decode($request->getContent(), true);
$content = $data['content'] ?? null;
if (!$content || trim($content) === '') {
return $this->json(['error' => 'Content cannot be empty'], Response::HTTP_BAD_REQUEST);
}
// Trim and limit content length
$content = trim($content);
if (strlen($content) > 500) {
return $this->json(['error' => 'Content cannot exceed 500 characters'], Response::HTTP_BAD_REQUEST);
}
$post = new TrashTalk();
$post->setSeason($season);
$post->setUser($user);
$post->setContent($content);
$this->entityManager->persist($post);
$this->entityManager->flush();
// Send Google Chat notification
$this->sendGoogleChatNotification($post);
return $this->json([
'post' => $this->serializePost($post)
], Response::HTTP_CREATED);
}
#[Route('/{id}', name: 'api_trash_talk_update', methods: ['PUT'])]
public function update(int $id, Request $request): Response
{
$post = $this->trashTalkRepository->find($id);
if (!$post) {
return $this->json(['error' => 'Post not found'], Response::HTTP_NOT_FOUND);
}
/** @var User $user */
$user = $this->getUser();
// Only the author can edit their post
if ($post->getUser() !== $user) {
return $this->json(['error' => 'You can only edit your own posts'], Response::HTTP_FORBIDDEN);
}
// Check if post is deleted
if ($post->isIsDeleted()) {
return $this->json(['error' => 'Cannot edit a deleted post'], Response::HTTP_FORBIDDEN);
}
$data = json_decode($request->getContent(), true);
$content = $data['content'] ?? null;
if (!$content || trim($content) === '') {
return $this->json(['error' => 'Content cannot be empty'], Response::HTTP_BAD_REQUEST);
}
// Trim and limit content length
$content = trim($content);
if (strlen($content) > 500) {
return $this->json(['error' => 'Content cannot exceed 500 characters'], Response::HTTP_BAD_REQUEST);
}
$post->setContent($content);
$post->setUpdatedAt(new \DateTimeImmutable());
$this->entityManager->flush();
return $this->json([
'post' => $this->serializePost($post)
]);
}
#[Route('/{id}', name: 'api_trash_talk_delete', methods: ['DELETE'])]
public function delete(int $id): Response
{
$post = $this->trashTalkRepository->find($id);
if (!$post) {
return $this->json(['error' => 'Post not found'], Response::HTTP_NOT_FOUND);
}
/** @var User $user */
$user = $this->getUser();
// Check if user can delete this post
if (!$this->canUserDeletePost($user, $post)) {
return $this->json(['error' => 'You do not have permission to delete this post'], Response::HTTP_FORBIDDEN);
}
// Soft delete
$post->setIsDeleted(true);
$this->entityManager->flush();
return $this->json([
'success' => true,
'post' => $this->serializePost($post)
]);
}
/**
* Check if user is on a team in the given season
*/
private function isUserInSeasonTeam(User $user, $season): bool
{
$userTeams = $this->userTeamRepository->findBy(['user' => $user]);
foreach ($userTeams as $userTeam) {
$team = $userTeam->getTeam();
$teamSeasons = $team->getTeamSeasons();
foreach ($teamSeasons as $teamSeason) {
if ($teamSeason->getSeason() === $season) {
return true;
}
}
}
return false;
}
/**
* Check if user can delete a post
* User can delete if:
* - They are the author
* - They are a team captain of a team in the season
* - They have ROLE_ADMIN
*/
private function canUserDeletePost(User $user, TrashTalk $post): bool
{
// Admin can delete any post
if ($this->isGranted('ROLE_ADMIN')) {
return true;
}
// Author can delete their own post
if ($post->getUser() === $user) {
return true;
}
// Team captain can delete posts
$userTeams = $this->userTeamRepository->findBy(['user' => $user]);
foreach ($userTeams as $userTeam) {
if ($userTeam->getRole() === 'captain') {
$team = $userTeam->getTeam();
$teamSeasons = $team->getTeamSeasons();
foreach ($teamSeasons as $teamSeason) {
if ($teamSeason->getSeason() === $post->getSeason()) {
return true;
}
}
}
}
return false;
}
/**
* Serialize a single post
*/
private function serializePost(TrashTalk $post): array
{
$user = $post->getUser();
$userTeam = $user->getUserTeam();
$profile = $user->getProfile();
return [
'id' => $post->getId(),
'content' => $post->getContent(),
'createdAt' => $post->getCreatedAt()->format('c'),
'updatedAt' => $post->getUpdatedAt()?->format('c'),
'isDeleted' => $post->isIsDeleted(),
'user' => [
'id' => $user->getId(),
'firstName' => $user->getFirstName(),
'lastName' => $user->getLastName(),
'fullName' => $user->getFullName(),
'avatar' => $profile?->getAvatar(),
'team' => $userTeam ? [
'id' => $userTeam->getTeam()->getId(),
'name' => $userTeam->getTeam()->getName()
] : null
]
];
}
/**
* Serialize multiple posts
*/
private function serializePosts(array $posts): array
{
return array_map(fn($post) => $this->serializePost($post), $posts);
}
/**
* Send Google Chat notification for a trash talk post
*/
private function sendGoogleChatNotification(TrashTalk $post): void
{
$season = $post->getSeason();
$config = $season->getGoogleChatConfig();
// Check if Google Chat is configured and enabled
if (!$config || !$config->isIsEnabled()) {
return;
}
// Check if trash_talk message type is enabled
if (!$config->isMessageTypeEnabled('trash_talk')) {
$this->logger->info('Trash talk notifications are disabled for this season', [
'season' => $season->getName(),
]);
return;
}
// Check if webhook URL is configured
if (!$config->getWebhookUrl()) {
$this->logger->warning('No webhook URL configured for Google Chat', [
'season' => $season->getName(),
]);
return;
}
// Rate limiting: Only send one notification every 30 minutes per season
$messageTypeConfig = $config->getMessageTypeConfig('trash_talk');
if ($messageTypeConfig) {
$lastNotificationTime = $messageTypeConfig->getConfigValue('last_notification_time');
if ($lastNotificationTime) {
$lastTime = new \DateTime($lastNotificationTime);
$now = new \DateTime();
$diffInMinutes = ($now->getTimestamp() - $lastTime->getTimestamp()) / 60;
if ($diffInMinutes < 30) {
$remainingMinutes = ceil(30 - $diffInMinutes);
$this->logger->info('Trash talk notification rate limited', [
'season' => $season->getName(),
'remaining_minutes' => $remainingMinutes,
]);
return;
}
}
}
try {
// Try to send as a card first (richer format)
$cardPayload = $this->trashTalkMessageGenerator->generateCardForPost($post);
$response = $this->httpClient->request('POST', $config->getWebhookUrl(), [
'json' => $cardPayload,
]);
if ($response->getStatusCode() === 200) {
$this->logger->info('Google Chat card notification sent for trash talk', [
'season' => $season->getName(),
'post_id' => $post->getId(),
'user' => $post->getUser()->getFullName(),
]);
// Update last notification time
$this->updateLastNotificationTime($messageTypeConfig);
return;
}
} catch (\Exception $e) {
// Card failed, fall back to simple text message
$this->logger->warning('Failed to send card, falling back to text message', [
'error' => $e->getMessage(),
]);
}
try {
// Fallback to simple text message
$message = $this->trashTalkMessageGenerator->generateForPost($post);
$response = $this->httpClient->request('POST', $config->getWebhookUrl(), [
'json' => [
'text' => $message,
],
]);
if ($response->getStatusCode() === 200) {
$this->logger->info('Google Chat text notification sent for trash talk', [
'season' => $season->getName(),
'post_id' => $post->getId(),
'user' => $post->getUser()->getFullName(),
]);
// Update last notification time
$this->updateLastNotificationTime($messageTypeConfig);
}
} catch (\Exception $e) {
$this->logger->error('Failed to send Google Chat notification for trash talk', [
'season' => $season->getName(),
'post_id' => $post->getId(),
'error' => $e->getMessage(),
]);
}
}
/**
* Update the last notification time for rate limiting
*/
private function updateLastNotificationTime($messageTypeConfig): void
{
if (!$messageTypeConfig) {
return;
}
try {
$now = new \DateTime();
$messageTypeConfig->setConfigValue('last_notification_time', $now->format('Y-m-d H:i:s'));
$this->entityManager->persist($messageTypeConfig);
$this->entityManager->flush();
$this->logger->debug('Updated last notification time for trash talk', [
'time' => $now->format('Y-m-d H:i:s'),
]);
} catch (\Exception $e) {
$this->logger->error('Failed to update last notification time', [
'error' => $e->getMessage(),
]);
}
}
}