feat : ajout des notifications

This commit is contained in:
2026-03-02 16:17:08 +01:00
parent e0f2a84f2c
commit 7a3d01d77f
14 changed files with 512 additions and 3 deletions

134
src/Entity/Notification.php Normal file
View File

@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use App\Repository\NotificationRepository;
use App\State\MarkAllNotificationsReadProcessor;
use App\State\UnreadNotificationsProvider;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(
uriTemplate: '/notifications/unread',
normalizationContext: ['groups' => ['notification:read']],
security: "is_granted('ROLE_USER')",
provider: UnreadNotificationsProvider::class,
paginationEnabled: false
),
new Post(
uriTemplate: '/notifications/mark-all-read',
security: "is_granted('ROLE_USER')",
input: false,
output: false,
read: false,
processor: MarkAllNotificationsReadProcessor::class
),
]
)]
#[ORM\Entity(repositoryClass: NotificationRepository::class)]
#[ORM\Table(name: 'notifications')]
#[ORM\Index(columns: ['recipient_id', 'is_read', 'created_at'], name: 'idx_notifications_recipient_read_created')]
class Notification
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
#[Groups(['notification:read'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private ?User $recipient = null;
#[ORM\Column(type: 'string', length: 120)]
#[Groups(['notification:read'])]
private string $title = '';
#[ORM\Column(type: 'text')]
#[Groups(['notification:read'])]
private string $message = '';
#[ORM\Column(type: 'boolean', options: ['default' => false])]
#[Groups(['notification:read'])]
private bool $isRead = false;
#[ORM\Column(type: 'datetime_immutable')]
#[Groups(['notification:read'])]
private DateTimeImmutable $createdAt;
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
}
public function getId(): ?int
{
return $this->id;
}
public function getRecipient(): ?User
{
return $this->recipient;
}
public function setRecipient(?User $recipient): self
{
$this->recipient = $recipient;
return $this;
}
public function getTitle(): string
{
return $this->title;
}
public function setTitle(string $title): self
{
$this->title = $title;
return $this;
}
public function getMessage(): string
{
return $this->message;
}
public function setMessage(string $message): self
{
$this->message = $message;
return $this;
}
public function isRead(): bool
{
return $this->isRead;
}
public function getIsRead(): bool
{
return $this->isRead;
}
public function setIsRead(bool $isRead): self
{
$this->isRead = $isRead;
return $this;
}
public function getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;
}
}

View File

@@ -10,6 +10,7 @@ use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Repository\UserRepository;
use App\State\CurrentUserProvider;
use App\State\UserPasswordHasherProcessor;
use Doctrine\Common\Collections\ArrayCollection;
@@ -52,7 +53,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
),
]
)]
#[ORM\Entity]
#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\Table(name: 'users')]
#[ORM\UniqueConstraint(name: 'uniq_users_username', fields: ['username'])]
class User implements UserInterface, PasswordAuthenticatedUserInterface

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Notification;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Notification>
*/
final class NotificationRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Notification::class);
}
/**
* @return list<Notification>
*/
public function findUnreadByRecipient(User $recipient): array
{
return $this->createQueryBuilder('n')
->andWhere('n.recipient = :recipient')
->andWhere('n.isRead = :isRead')
->setParameter('recipient', $recipient)
->setParameter('isRead', false)
->orderBy('n.createdAt', 'DESC')
->setMaxResults(50)
->getQuery()
->getResult()
;
}
public function markAllReadByRecipient(User $recipient): int
{
return $this->createQueryBuilder('n')
->update()
->set('n.isRead', ':isRead')
->andWhere('n.recipient = :recipient')
->andWhere('n.isRead = :current')
->setParameter('isRead', true)
->setParameter('current', false)
->setParameter('recipient', $recipient)
->getQuery()
->execute()
;
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<User>
*/
final class UserRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, User::class);
}
/**
* @return list<User>
*/
public function findAllAdmins(): array
{
/** @var list<User> $users */
$users = $this->createQueryBuilder('u')
->orderBy('u.id', 'ASC')
->getQuery()
->getResult()
;
return array_values(array_filter(
$users,
static fn (User $user): bool => in_array('ROLE_ADMIN', $user->getRoles(), true)
));
}
}

View File

@@ -137,4 +137,23 @@ final class WorkHourRepository extends ServiceEntityRepository implements WorkHo
// @var null|WorkHour $workHour
return $qb->getQuery()->getOneOrNullResult();
}
public function hasPendingSiteValidationForSiteAndDate(int $siteId, DateTimeInterface $date): bool
{
$workDate = DateTimeImmutable::createFromInterface($date);
$qb = $this->createQueryBuilder('w')
->select('COUNT(w.id)')
->leftJoin('w.employee', 'e')
->leftJoin('e.site', 's')
->andWhere('s.id = :siteId')
->andWhere('w.workDate = :workDate')
->andWhere('w.isSiteValid = :isSiteValid')
->setParameter('siteId', $siteId)
->setParameter('workDate', $workDate)
->setParameter('isSiteValid', false)
;
return ((int) $qb->getQuery()->getSingleScalarResult()) > 0;
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\User;
use App\Repository\NotificationRepository;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
final readonly class MarkAllNotificationsReadProcessor implements ProcessorInterface
{
public function __construct(
private Security $security,
private NotificationRepository $notificationRepository,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): null
{
$user = $this->security->getUser();
if (!$user instanceof User) {
throw new AccessDeniedHttpException('Authentication required.');
}
$this->notificationRepository->markAllReadByRecipient($user);
return null;
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\User;
use App\Repository\NotificationRepository;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
final readonly class UnreadNotificationsProvider implements ProviderInterface
{
public function __construct(
private Security $security,
private NotificationRepository $notificationRepository,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
{
$user = $this->security->getUser();
if (!$user instanceof User) {
throw new AccessDeniedHttpException('Authentication required.');
}
return $this->notificationRepository->findUnreadByRecipient($user);
}
}

View File

@@ -6,8 +6,11 @@ namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Notification;
use App\Entity\User;
use App\Entity\WorkHour;
use App\Repository\UserRepository;
use App\Repository\WorkHourRepository;
use App\Security\EmployeeScopeService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
@@ -18,6 +21,8 @@ final readonly class WorkHourSiteValidationProcessor implements ProcessorInterfa
public function __construct(
private Security $security,
private EmployeeScopeService $employeeScopeService,
private WorkHourRepository $workHourRepository,
private UserRepository $userRepository,
private EntityManagerInterface $entityManager,
) {}
@@ -47,8 +52,38 @@ final readonly class WorkHourSiteValidationProcessor implements ProcessorInterfa
throw new AccessDeniedHttpException('Employee is outside your site scope.');
}
$uow = $this->entityManager->getUnitOfWork();
$uow->computeChangeSets();
$changeSet = $uow->getEntityChangeSet($data);
$isSiteValidationChangedToTrue = isset($changeSet['isSiteValid'])
&& false === $changeSet['isSiteValid'][0]
&& true === $changeSet['isSiteValid'][1];
$this->entityManager->flush();
// Notification uniquement quand la dernière ligne du site est validée pour la date.
if ($isSiteValidationChangedToTrue) {
$workDate = $data->getWorkDate();
$hasPending = $this->workHourRepository->hasPendingSiteValidationForSiteAndDate($siteId, $workDate);
if (!$hasPending) {
$siteName = $data->getEmployee()?->getSite()?->getName() ?? 'Site';
$dateLabel = $workDate->format('d/m/Y');
$title = sprintf('%s validé', $siteName);
$message = sprintf('Le site %s a terminé la validation du %s.', $siteName, $dateLabel);
foreach ($this->userRepository->findAllAdmins() as $admin) {
$notification = new Notification()
->setRecipient($admin)
->setTitle($title)
->setMessage($message)
;
$this->entityManager->persist($notification);
}
$this->entityManager->flush();
}
}
return $data;
}
}