From 7a3d01d77fb2a47ce7a934c2bb32facd012756c8 Mon Sep 17 00:00:00 2001 From: tristan Date: Mon, 2 Mar 2026 16:17:08 +0100 Subject: [PATCH] feat : ajout des notifications --- doc/functional-rules.md | 13 ++ frontend/components/AppTopNav.vue | 101 ++++++++++++- frontend/nuxt.config.ts | 2 +- frontend/services/dto/notification.ts | 7 + frontend/services/notifications.ts | 18 +++ migrations/Version20260302110000.php | 30 ++++ src/Entity/Notification.php | 134 ++++++++++++++++++ src/Entity/User.php | 3 +- src/Repository/NotificationRepository.php | 53 +++++++ src/Repository/UserRepository.php | 38 +++++ src/Repository/WorkHourRepository.php | 19 +++ .../MarkAllNotificationsReadProcessor.php | 32 +++++ src/State/UnreadNotificationsProvider.php | 30 ++++ src/State/WorkHourSiteValidationProcessor.php | 35 +++++ 14 files changed, 512 insertions(+), 3 deletions(-) create mode 100644 frontend/services/dto/notification.ts create mode 100644 frontend/services/notifications.ts create mode 100644 migrations/Version20260302110000.php create mode 100644 src/Entity/Notification.php create mode 100644 src/Repository/NotificationRepository.php create mode 100644 src/Repository/UserRepository.php create mode 100644 src/State/MarkAllNotificationsReadProcessor.php create mode 100644 src/State/UnreadNotificationsProvider.php diff --git a/doc/functional-rules.md b/doc/functional-rules.md index 93902b1..bd6fb3c 100644 --- a/doc/functional-rules.md +++ b/doc/functional-rules.md @@ -132,3 +132,16 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer. - uniquement prénom, nom, site - pas de modification de contrat depuis ce drawer +## 10) Notifications + +- Icône cloche en topbar: + - badge = nombre de notifications non lues + - ouverture panneau = liste des non lues + - fermeture panneau = marquage "lu" en masse + +### Règle métier de déclenchement + +- Les notifications de validation site ne sont pas envoyées ligne par ligne. +- Une notification est créée uniquement quand un chef de site termine la validation complète: + - condition: plus aucune ligne `work_hours` du site à la date concernée avec `isSiteValid = false` + - destinataires: utilisateurs `ROLE_ADMIN` diff --git a/frontend/components/AppTopNav.vue b/frontend/components/AppTopNav.vue index 4ceed70..3380dc4 100644 --- a/frontend/components/AppTopNav.vue +++ b/frontend/components/AppTopNav.vue @@ -2,7 +2,42 @@
- +
+ + +
+
+ Notifications +
+
+ Chargement... +
+
+ Aucune notification. +
+
+
+

{{ notification.title }}

+

{{ notification.message }}

+
+
+
+

{{ user?.username }}

@@ -29,15 +64,79 @@ diff --git a/frontend/nuxt.config.ts b/frontend/nuxt.config.ts index 4dd0d11..374000f 100644 --- a/frontend/nuxt.config.ts +++ b/frontend/nuxt.config.ts @@ -22,7 +22,7 @@ export default defineNuxtConfig({ devServer: {port: 3001}, toast: { settings: { - timeout: 10000, + timeout: 2000, closeOnClick: true, progressBar: false } diff --git a/frontend/services/dto/notification.ts b/frontend/services/dto/notification.ts new file mode 100644 index 0000000..edfd9e1 --- /dev/null +++ b/frontend/services/dto/notification.ts @@ -0,0 +1,7 @@ +export type NotificationItem = { + id: number + title: string + message: string + isRead: boolean + createdAt: string +} diff --git a/frontend/services/notifications.ts b/frontend/services/notifications.ts new file mode 100644 index 0000000..7d8a96e --- /dev/null +++ b/frontend/services/notifications.ts @@ -0,0 +1,18 @@ +import type { NotificationItem } from './dto/notification' +import { extractItems } from '~/utils/api' + +export const listUnreadNotifications = async () => { + const api = useApi() + const data = await api.get( + '/notifications/unread', + {}, + { toast: false } + ) + + return extractItems(data) +} + +export const markAllNotificationsRead = async () => { + const api = useApi() + return api.post('/notifications/mark-all-read', {}, { toast: false }) +} diff --git a/migrations/Version20260302110000.php b/migrations/Version20260302110000.php new file mode 100644 index 0000000..9ab5824 --- /dev/null +++ b/migrations/Version20260302110000.php @@ -0,0 +1,30 @@ +addSql('CREATE TABLE notifications (id SERIAL NOT NULL, recipient_id INT NOT NULL, title VARCHAR(120) NOT NULL, message TEXT NOT NULL, is_read BOOLEAN DEFAULT FALSE NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX idx_notifications_recipient_read_created ON notifications (recipient_id, is_read, created_at)'); + $this->addSql('CREATE INDEX IDX_6000B0D0E92F8F78 ON notifications (recipient_id)'); + $this->addSql('ALTER TABLE notifications ADD CONSTRAINT FK_6000B0D0E92F8F78 FOREIGN KEY (recipient_id) REFERENCES users (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE notifications DROP CONSTRAINT FK_6000B0D0E92F8F78'); + $this->addSql('DROP TABLE notifications'); + } +} diff --git a/src/Entity/Notification.php b/src/Entity/Notification.php new file mode 100644 index 0000000..bbad842 --- /dev/null +++ b/src/Entity/Notification.php @@ -0,0 +1,134 @@ + ['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; + } +} diff --git a/src/Entity/User.php b/src/Entity/User.php index 329935a..2164fd4 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -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 diff --git a/src/Repository/NotificationRepository.php b/src/Repository/NotificationRepository.php new file mode 100644 index 0000000..8a4ada7 --- /dev/null +++ b/src/Repository/NotificationRepository.php @@ -0,0 +1,53 @@ + + */ +final class NotificationRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Notification::class); + } + + /** + * @return list + */ + 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() + ; + } +} diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php new file mode 100644 index 0000000..50f5d97 --- /dev/null +++ b/src/Repository/UserRepository.php @@ -0,0 +1,38 @@ + + */ +final class UserRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, User::class); + } + + /** + * @return list + */ + public function findAllAdmins(): array + { + /** @var list $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) + )); + } +} diff --git a/src/Repository/WorkHourRepository.php b/src/Repository/WorkHourRepository.php index 8c67deb..21eeb4e 100644 --- a/src/Repository/WorkHourRepository.php +++ b/src/Repository/WorkHourRepository.php @@ -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; + } } diff --git a/src/State/MarkAllNotificationsReadProcessor.php b/src/State/MarkAllNotificationsReadProcessor.php new file mode 100644 index 0000000..0fd0970 --- /dev/null +++ b/src/State/MarkAllNotificationsReadProcessor.php @@ -0,0 +1,32 @@ +security->getUser(); + if (!$user instanceof User) { + throw new AccessDeniedHttpException('Authentication required.'); + } + + $this->notificationRepository->markAllReadByRecipient($user); + + return null; + } +} diff --git a/src/State/UnreadNotificationsProvider.php b/src/State/UnreadNotificationsProvider.php new file mode 100644 index 0000000..890ea3b --- /dev/null +++ b/src/State/UnreadNotificationsProvider.php @@ -0,0 +1,30 @@ +security->getUser(); + if (!$user instanceof User) { + throw new AccessDeniedHttpException('Authentication required.'); + } + + return $this->notificationRepository->findUnreadByRecipient($user); + } +} diff --git a/src/State/WorkHourSiteValidationProcessor.php b/src/State/WorkHourSiteValidationProcessor.php index 4005df9..0bc0915 100644 --- a/src/State/WorkHourSiteValidationProcessor.php +++ b/src/State/WorkHourSiteValidationProcessor.php @@ -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; } }