From 2d6784390152de68c21a5e74dca1beb20aaa1ca6 Mon Sep 17 00:00:00 2001 From: Ronchie Blondiau Date: Fri, 5 Jul 2024 13:36:31 +0000 Subject: [PATCH] added unread and read all function with endpoints for notifications --- .../unreleased/Feature-20240705-152111.yaml | 7 + .../Controller/NotificationApiController.php | 34 ++ .../Controller/NotificationController.php | 8 +- .../Repository/NotificationRepository.php | 109 +++++- .../public/module/notification/toggle_read.js | 91 +++-- .../module/notification/toggle_read_all.ts | 39 ++ .../NotificationReadAllToggle.vue | 50 +++ .../Notification/NotificationReadToggle.vue | 122 +++--- .../views/Notification/_list_item.html.twig | 151 +++++--- .../views/Notification/list.html.twig | 108 +++--- .../Repository/NewsItemRepositoryTest.php | 2 +- .../Repository/NotificationRepositoryTest.php | 95 +++++ .../ChillMainBundle/chill.api.specs.yaml | 365 ++++++++++-------- .../ChillMainBundle/chill.webpack.config.js | 1 + 14 files changed, 805 insertions(+), 377 deletions(-) create mode 100644 .changes/unreleased/Feature-20240705-152111.yaml create mode 100644 src/Bundle/ChillMainBundle/Resources/public/module/notification/toggle_read_all.ts create mode 100644 src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/Notification/NotificationReadAllToggle.vue create mode 100644 src/Bundle/ChillMainBundle/Tests/Repository/NotificationRepositoryTest.php diff --git a/.changes/unreleased/Feature-20240705-152111.yaml b/.changes/unreleased/Feature-20240705-152111.yaml new file mode 100644 index 000000000..8dc2f6e94 --- /dev/null +++ b/.changes/unreleased/Feature-20240705-152111.yaml @@ -0,0 +1,7 @@ +kind: Feature +body: |+ + Add the possibility to mark all notifications as read + +time: 2024-07-05T15:21:11.730543489+02:00 +custom: + Issue: "273" diff --git a/src/Bundle/ChillMainBundle/Controller/NotificationApiController.php b/src/Bundle/ChillMainBundle/Controller/NotificationApiController.php index 42f0e5469..61459f4d8 100644 --- a/src/Bundle/ChillMainBundle/Controller/NotificationApiController.php +++ b/src/Bundle/ChillMainBundle/Controller/NotificationApiController.php @@ -104,4 +104,38 @@ class NotificationApiController return new JsonResponse(null, JsonResponse::HTTP_ACCEPTED, [], false); } + + /** + * @Route("/mark/allread", name="chill_api_main_notification_mark_allread", methods={"POST"}) + */ + public function markAllRead(): JsonResponse + { + $user = $this->security->getUser(); + + if (!$user instanceof User) { + throw new \RuntimeException('Invalid user'); + } + + $modifiedNotificationIds = $this->notificationRepository->markAllNotificationAsReadForUser($user); + + return new JsonResponse($modifiedNotificationIds); + } + + /** + * @Route("/mark/undoallread", name="chill_api_main_notification_mark_undoallread", methods={"POST"}) + */ + public function undoAllRead(Request $request): JsonResponse + { + $user = $this->security->getUser(); + + if (!$user instanceof User) { + throw new \RuntimeException('Invalid user'); + } + + $ids = json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR); + + $touchedIds = $this->notificationRepository->markAllNotificationAsUnreadForUser($user, $ids); + + return new JsonResponse($touchedIds); + } } diff --git a/src/Bundle/ChillMainBundle/Controller/NotificationController.php b/src/Bundle/ChillMainBundle/Controller/NotificationController.php index 4d962248c..f9ae1be02 100644 --- a/src/Bundle/ChillMainBundle/Controller/NotificationController.php +++ b/src/Bundle/ChillMainBundle/Controller/NotificationController.php @@ -193,13 +193,17 @@ class NotificationController extends AbstractController $this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED'); $currentUser = $this->security->getUser(); + if (!$currentUser instanceof User) { + throw new AccessDeniedHttpException('Only regular user should access this page'); + } + $notificationsNbr = $this->notificationRepository->countAllForAttendee($currentUser); $paginator = $this->paginatorFactory->create($notificationsNbr); $notifications = $this->notificationRepository->findAllForAttendee( $currentUser, - $limit = $paginator->getItemsPerPage(), - $offset = $paginator->getCurrentPage()->getFirstItemNumber() + $paginator->getItemsPerPage(), + $paginator->getCurrentPage()->getFirstItemNumber() ); return $this->render('@ChillMain/Notification/list.html.twig', [ diff --git a/src/Bundle/ChillMainBundle/Repository/NotificationRepository.php b/src/Bundle/ChillMainBundle/Repository/NotificationRepository.php index ef7e05240..fb79a7397 100644 --- a/src/Bundle/ChillMainBundle/Repository/NotificationRepository.php +++ b/src/Bundle/ChillMainBundle/Repository/NotificationRepository.php @@ -13,6 +13,8 @@ namespace Chill\MainBundle\Repository; use Chill\MainBundle\Entity\Notification; use Chill\MainBundle\Entity\User; +use Doctrine\DBAL\Connection; +use Doctrine\DBAL\Result; use Doctrine\DBAL\Statement; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\EntityManagerInterface; @@ -81,10 +83,7 @@ final class NotificationRepository implements ObjectRepository $results->free(); } else { $wheres = []; - foreach ([ - ['relatedEntityClass' => $relatedEntityClass, 'relatedEntityId' => $relatedEntityId], - ...$more, - ] as $k => ['relatedEntityClass' => $relClass, 'relatedEntityId' => $relId]) { + foreach ([['relatedEntityClass' => $relatedEntityClass, 'relatedEntityId' => $relatedEntityId], ...$more] as $k => ['relatedEntityClass' => $relClass, 'relatedEntityId' => $relId]) { $wheres[] = "(relatedEntityClass = :relatedEntityClass_{$k} AND relatedEntityId = :relatedEntityId_{$k})"; $sqlParams["relatedEntityClass_{$k}"] = $relClass; $sqlParams["relatedEntityId_{$k}"] = $relId; @@ -228,11 +227,11 @@ final class NotificationRepository implements ObjectRepository $rsm->addRootEntityFromClassMetadata(Notification::class, 'cmn'); $sql = 'SELECT '.$rsm->generateSelectClause(['cmn' => 'cmn']).' '. - 'FROM chill_main_notification cmn '. - 'WHERE '. - 'EXISTS (select 1 FROM chill_main_notification_addresses_unread cmnau WHERE cmnau.user_id = :userId and cmnau.notification_id = cmn.id) '. - 'ORDER BY cmn.date DESC '. - 'LIMIT :limit OFFSET :offset'; + 'FROM chill_main_notification cmn '. + 'WHERE '. + 'EXISTS (select 1 FROM chill_main_notification_addresses_unread cmnau WHERE cmnau.user_id = :userId and cmnau.notification_id = cmn.id) '. + 'ORDER BY cmn.date DESC '. + 'LIMIT :limit OFFSET :offset'; $nq = $this->em->createNativeQuery($sql, $rsm) ->setParameter('userId', $user->getId()) @@ -255,10 +254,12 @@ final class NotificationRepository implements ObjectRepository $qb = $this->repository->createQueryBuilder('n'); // add condition for related entity (in main arguments, and in more) - $or = $qb->expr()->orX($qb->expr()->andX( - $qb->expr()->eq('n.relatedEntityClass', ':relatedEntityClass'), - $qb->expr()->eq('n.relatedEntityId', ':relatedEntityId') - )); + $or = $qb->expr()->orX( + $qb->expr()->andX( + $qb->expr()->eq('n.relatedEntityClass', ':relatedEntityClass'), + $qb->expr()->eq('n.relatedEntityId', ':relatedEntityId') + ) + ); $qb ->setParameter('relatedEntityClass', $relatedEntityClass) ->setParameter('relatedEntityId', $relatedEntityId); @@ -310,4 +311,86 @@ final class NotificationRepository implements ObjectRepository return $qb; } + + /** + * @return list the ids of the notifications marked as unread + */ + public function markAllNotificationAsReadForUser(User $user): array + { + // Get the database connection from the entity manager + $connection = $this->em->getConnection(); + + /** @var Result $results */ + $results = $connection->transactional(function (Connection $connection) use ($user) { + // Define the SQL query + $sql = <<<'SQL' + DELETE FROM chill_main_notification_addresses_unread + WHERE user_id = :user_id + RETURNING notification_id + SQL; + + return $connection->executeQuery($sql, ['user_id' => $user->getId()]); + }); + + $notificationIdsTouched = []; + foreach ($results->iterateAssociative() as $row) { + $notificationIdsTouched[] = $row['notification_id']; + } + + return array_values($notificationIdsTouched); + } + + /** + * @param list $notificationIds + */ + public function markAllNotificationAsUnreadForUser(User $user, array $notificationIds): array + { + // Get the database connection from the entity manager + $connection = $this->em->getConnection(); + + /** @var Result $results */ + $results = $connection->transactional(function (Connection $connection) use ($user, $notificationIds) { + // This query double-check that the user is one of the addresses of the notification or the sender, + // if the notification is already marked as unread, this query does not fails. + // this query return the list of notification id which are affected + $sql = <<<'SQL' + INSERT INTO chill_main_notification_addresses_unread (user_id, notification_id) + SELECT ?, chill_main_notification_addresses_user.notification_id + FROM chill_main_notification_addresses_user JOIN chill_main_notification ON chill_main_notification_addresses_user.notification_id = chill_main_notification.id + WHERE (chill_main_notification_addresses_user.user_id = ? OR chill_main_notification.sender_id = ?) + AND chill_main_notification_addresses_user.notification_id IN ({ notification_ids }) + ON CONFLICT (user_id, notification_id) DO NOTHING + RETURNING notification_id + SQL; + + $params = [$user->getId(), $user->getId(), $user->getId(), ...array_values($notificationIds)]; + $sql = strtr($sql, ['{ notification_ids }' => implode(', ', array_fill(0, count($notificationIds), '?'))]); + + return $connection->executeQuery($sql, $params); + }); + + $notificationIdsTouched = []; + foreach ($results->iterateAssociative() as $row) { + $notificationIdsTouched[] = $row['notification_id']; + } + + return array_values($notificationIdsTouched); + } + + public function findAllUnreadByUser(User $user): array + { + $rsm = new Query\ResultSetMappingBuilder($this->em); + $rsm->addRootEntityFromClassMetadata(Notification::class, 'cmn'); + + $sql = 'SELECT '.$rsm->generateSelectClause(['cmn' => 'cmn']).' '. + 'FROM chill_main_notification cmn '. + 'WHERE '. + 'EXISTS (SELECT 1 FROM chill_main_notification_addresses_unread cmnau WHERE cmnau.user_id = :userId AND cmnau.notification_id = cmn.id) '. + 'ORDER BY cmn.date DESC'; + + $nq = $this->em->createNativeQuery($sql, $rsm) + ->setParameter('userId', $user->getId()); + + return $nq->getResult(); + } } diff --git a/src/Bundle/ChillMainBundle/Resources/public/module/notification/toggle_read.js b/src/Bundle/ChillMainBundle/Resources/public/module/notification/toggle_read.js index ae092f8e2..7dc5951db 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/module/notification/toggle_read.js +++ b/src/Bundle/ChillMainBundle/Resources/public/module/notification/toggle_read.js @@ -1,14 +1,16 @@ -import {createApp} from "vue"; +import { createApp } from "vue"; import NotificationReadToggle from "ChillMainAssets/vuejs/_components/Notification/NotificationReadToggle.vue"; import { _createI18n } from "ChillMainAssets/vuejs/_js/i18n"; +import NotificationReadAllToggle from "ChillMainAssets/vuejs/_components/Notification/NotificationReadAllToggle.vue"; const i18n = _createI18n({}); -window.addEventListener('DOMContentLoaded', function (e) { - document.querySelectorAll('.notification_toggle_read_status') - .forEach(function (el, i) { - createApp({ - template: ` `, - components: { - NotificationReadToggle, - }, - data() { - return { - notificationId: el.dataset.notificationId, - buttonClass: el.dataset.buttonClass, - buttonNoText: 'false' === el.dataset.buttonText, - showUrl: el.dataset.showButtonUrl, - isRead: 1 === Number.parseInt(el.dataset.notificationCurrentIsRead), - container: el.dataset.container - } - }, - computed: { - getContainer() { - return document.querySelectorAll(`div.${this.container}`); - } - }, - methods: { - onMarkRead() { - if (typeof this.getContainer[i] !== 'undefined') { - this.getContainer[i].classList.replace('read', 'unread'); - } else { throw 'data-container attribute is missing' } - this.isRead = false; - }, - onMarkUnread() { - if (typeof this.getContainer[i] !== 'undefined') { - this.getContainer[i].classList.replace('unread', 'read'); - } else { throw 'data-container attribute is missing' } - this.isRead = true; - }, - } - }) - .use(i18n) - .mount(el); - }); + components: { + NotificationReadToggle, + }, + data() { + return { + notificationId: parseInt(el.dataset.notificationId), + buttonClass: el.dataset.buttonClass, + buttonNoText: "false" === el.dataset.buttonText, + showUrl: el.dataset.showButtonUrl, + isRead: 1 === Number.parseInt(el.dataset.notificationCurrentIsRead), + container: el.dataset.container, + }; + }, + computed: { + getContainer() { + return document.querySelectorAll(`div.${this.container}`); + }, + }, + methods: { + onMarkRead() { + if (typeof this.getContainer[i] !== "undefined") { + this.getContainer[i].classList.replace("read", "unread"); + } else { + throw "data-container attribute is missing"; + } + this.isRead = false; + }, + onMarkUnread() { + if (typeof this.getContainer[i] !== "undefined") { + this.getContainer[i].classList.replace("unread", "read"); + } else { + throw "data-container attribute is missing"; + } + this.isRead = true; + }, + }, + }) + .use(i18n) + .mount(el); + }); }); + diff --git a/src/Bundle/ChillMainBundle/Resources/public/module/notification/toggle_read_all.ts b/src/Bundle/ChillMainBundle/Resources/public/module/notification/toggle_read_all.ts new file mode 100644 index 000000000..0cb08621e --- /dev/null +++ b/src/Bundle/ChillMainBundle/Resources/public/module/notification/toggle_read_all.ts @@ -0,0 +1,39 @@ +import { createApp } from "vue"; +import { _createI18n } from "../../vuejs/_js/i18n"; +import NotificationReadAllToggle from "../../vuejs/_components/Notification/NotificationReadAllToggle.vue"; + +const i18n = _createI18n({}); + +document.addEventListener("DOMContentLoaded", function () { + const elements = document.querySelectorAll(".notification_all_read"); + + elements.forEach((element) => { + console.log('launch'); + createApp({ + template: ``, + components: { + NotificationReadAllToggle, + }, + methods: { + markAsRead(id: number) { + const el = document.querySelector(`div.notification-status[data-notification-id="${id}"]`); + if (el === null) { + return; + } + el.classList.add('read'); + el.classList.remove('unread'); + }, + markAsUnread(id: number) { + const el = document.querySelector(`div.notification-status[data-notification-id="${id}"]`); + if (el === null) { + return; + } + el.classList.remove('read'); + el.classList.add('unread'); + }, + } + }) + .use(i18n) + .mount(element); + }); +}); diff --git a/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/Notification/NotificationReadAllToggle.vue b/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/Notification/NotificationReadAllToggle.vue new file mode 100644 index 000000000..d5dd37237 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/Notification/NotificationReadAllToggle.vue @@ -0,0 +1,50 @@ + + + + + diff --git a/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/Notification/NotificationReadToggle.vue b/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/Notification/NotificationReadToggle.vue index 7f56e40d6..f2384e6f1 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/Notification/NotificationReadToggle.vue +++ b/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/Notification/NotificationReadToggle.vue @@ -1,47 +1,66 @@ - + diff --git a/src/Bundle/ChillMainBundle/Resources/views/Notification/_list_item.html.twig b/src/Bundle/ChillMainBundle/Resources/views/Notification/_list_item.html.twig index 377c693ff..80074f32e 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/Notification/_list_item.html.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/Notification/_list_item.html.twig @@ -1,30 +1,33 @@ {% macro title(c) %} {% endmacro %} - {% macro header(c) %}
    {% if c.step is not defined or c.step == 'inbox' %}
  • - - - {{ 'notification.from'|trans }} : - - + + + {{ "notification.from" | trans }} : + + {% if not c.notification.isSystem %} - {{ c.notification.sender|chill_entity_render_string({'at_date': c.notification.date}) }} - + {{ c.notification.sender | chill_entity_render_string({'at_date': c.notification.date}) }} + {% else %} - {{ 'notification.is_system'|trans }} + {{ "notification.is_system" | trans }} {% endif %}
  • {% endif %} @@ -32,34 +35,37 @@
  • {% if c.notification_cc is defined %} {% if c.notification_cc %} - - - {{ 'notification.cc'|trans }} : - - + + + {{ "notification.cc" | trans }} : + + {% else %} - - - {{ 'notification.to'|trans }} : - - + + + {{ "notification.to" | trans }} : + + {% endif %} {% else %} - - - {{ 'notification.to'|trans }} : - - + + + {{ "notification.to" | trans }} : + + {% endif %} {% for a in c.notification.addressees %} - {{ a|chill_entity_render_string({'at_date': c.notification.date}) }} - + {{ a | chill_entity_render_string({'at_date': c.notification.date}) }} + {% endfor %} {% for a in c.notification.addressesEmails %} - - {{ a }} - + + {{ a }} + {% endfor %}
  • {% endif %} @@ -70,7 +76,6 @@
{% endmacro %} - {% macro content(c) %}
{% if c.data is defined %} @@ -83,60 +88,77 @@
{% if c.full_content is defined and c.full_content == true %} {% if c.notification.message is not empty %} - {{ c.notification.message|chill_markdown_to_html }} + {{ c.notification.message | chill_markdown_to_html }} {% else %} -

{{ 'Any comment'|trans }}

+

{{ "Any comment" | trans }}

{% endif %} {% else %} {% if c.notification.message is not empty %} {{ c.notification.message|u.truncate(250, '…', false)|chill_markdown_to_html }} -

{{ 'Read more'|trans }}

+

+ {{ "Read more" | trans }} +

{% else %} -

{{ 'Any comment'|trans }}

+

{{ "Any comment" | trans }}

{% endif %} {% endif %}
{% endmacro %} - {% macro actions(c) %} {% if c.action_button is not defined or c.action_button != false %}
- {% if c.notification.comments|length > 0 %}
- - {{ 'notification.counter comments'|trans({'nb': c.notification.comments|length }) }} - + + {{ 'notification.counter comments'|trans({'nb': c.notification.comments|length }) }} +
{% endif %} -
  • {# Vue component #} -
  • {% if is_granted('CHILL_MAIN_NOTIFICATION_UPDATE', c.notification) %}
  • - +
  • {% endif %} - {% if is_granted('CHILL_MAIN_NOTIFICATION_SEE', c.notification) %} + {% if is_granted('CHILL_MAIN_NOTIFICATION_SEE', + c.notification) %}
  • - + {% if not c.notification.isSystem() %} {% else %} - {{ 'Read more'|trans }} + {{ "Read more" | trans }} {% endif %}
  • @@ -147,24 +169,30 @@ {% endif %} {% endmacro %} -
    - +
    {% if fold_item is defined and fold_item != false %}
    - {{ _self.header(_context) }} -
    -
    - + data-bs-parent="#notification-fold" + > {{ _self.content(_context) }}
    {{ _self.actions(_context) }} @@ -174,5 +202,4 @@ {{ _self.content(_context) }} {{ _self.actions(_context) }} {% endif %} -
    diff --git a/src/Bundle/ChillMainBundle/Resources/views/Notification/list.html.twig b/src/Bundle/ChillMainBundle/Resources/views/Notification/list.html.twig index 90d103aaf..5feff8404 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/Notification/list.html.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/Notification/list.html.twig @@ -1,62 +1,78 @@ -{% extends "@ChillMain/layout.html.twig" %} +{% extends "@ChillMain/layout.html.twig" %} {% block title 'notification.My own notifications'|trans %} {% block js %} - {{ parent() }} - {{ encore_entry_script_tags('mod_notification_toggle_read_status') }} + {{ parent() }} + {{ encore_entry_script_tags("mod_notification_toggle_read_status") }} + {{ encore_entry_script_tags("mod_notification_toggle_read_all_status") }} {% endblock %} {% block css %} {{ parent() }} - {{ encore_entry_link_tags('mod_notification_toggle_read_status') }} + {{ encore_entry_link_tags("mod_notification_toggle_read_status") }} + {{ encore_entry_link_tags("mod_notification_toggle_read_all_status") }} {% endblock %} {% block content %} -
    -

    {{ block('title') }}

    +
    +

    {{ block("title") }}

    + - - - {% if datas|length == 0 %} - {% if step == 'inbox' %} -

    {{ 'notification.Any notification received'|trans }}

    + {% if datas|length == 0 %} {% if step == 'inbox' %} +

    + {{ "notification.Any notification received" | trans }} +

    {% else %} -

    {{ 'notification.Any notification sent'|trans }}

    +

    + {{ "notification.Any notification sent" | trans }} +

    {% endif %} - {% else %} -
    - {% for data in datas %} - {% set notification = data.notification %} - {% include '@ChillMain/Notification/_list_item.html.twig' with { - 'fold_item': true, - 'notification_cc': data.template_data.notificationCc is defined ? data.template_data.notificationCc : false - } %} - {% endfor %} -
    + {% else %} +
    + {% for data in datas %} + {% set notification = data.notification %} + {% include '@ChillMain/Notification/_list_item.html.twig' with { + 'fold_item': true, 'notification_cc': data.template_data.notificationCc + is defined ? data.template_data.notificationCc : false } %} + {% endfor %} +
    + + {{ chill_pagination(paginator) }} + {% endif %} + +
      +
    • + +
    • +
    +
    - {{ chill_pagination(paginator) }} - {% endif %} -
    {% endblock content %} diff --git a/src/Bundle/ChillMainBundle/Tests/Repository/NewsItemRepositoryTest.php b/src/Bundle/ChillMainBundle/Tests/Repository/NewsItemRepositoryTest.php index 7aab97e48..f91ed2068 100644 --- a/src/Bundle/ChillMainBundle/Tests/Repository/NewsItemRepositoryTest.php +++ b/src/Bundle/ChillMainBundle/Tests/Repository/NewsItemRepositoryTest.php @@ -9,7 +9,7 @@ declare(strict_types=1); * the LICENSE file that was distributed with this source code. */ -namespace Repository; +namespace Chill\MainBundle\Tests\Repository; use Chill\MainBundle\Entity\NewsItem; use Chill\MainBundle\Repository\NewsItemRepository; diff --git a/src/Bundle/ChillMainBundle/Tests/Repository/NotificationRepositoryTest.php b/src/Bundle/ChillMainBundle/Tests/Repository/NotificationRepositoryTest.php new file mode 100644 index 000000000..fddcc5eb9 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Tests/Repository/NotificationRepositoryTest.php @@ -0,0 +1,95 @@ +entityManager = static::$kernel->getContainer()->get('doctrine.orm.entity_manager'); + $this->repository = new NotificationRepository($this->entityManager); + } + + public function testMarkAllNotificationAsReadForUser(): void + { + $user = $this->entityManager->createQuery('SELECT u FROM '.User::class.' u') + ->setMaxResults(1)->getSingleResult(); + + $notification = (new Notification()) + ->setRelatedEntityClass('\Dummy') + ->setRelatedEntityId(0) + ; + $notification->addAddressee($user)->markAsUnreadBy($user); + + $this->entityManager->persist($notification); + $this->entityManager->flush(); + $notification->markAsUnreadBy($user); + $this->entityManager->flush(); + + $this->entityManager->refresh($notification); + + if ($notification->isReadBy($user)) { + throw new \LogicException('Notification should not be marked as read'); + } + + $notificationsIds = $this->repository->markAllNotificationAsReadForUser($user); + self::assertContains($notification->getId(), $notificationsIds); + + $this->entityManager->clear(); + + $notification = $this->entityManager->find(Notification::class, $notification->getId()); + + self::assertTrue($notification->isReadBy($user)); + } + + public function testMarkAllNotificationAsUnreadForUser(): void + { + $user = $this->entityManager->createQuery('SELECT u FROM '.User::class.' u') + ->setMaxResults(1)->getSingleResult(); + + $notification = (new Notification()) + ->setRelatedEntityClass('\Dummy') + ->setRelatedEntityId(0) + ; + $notification->addAddressee($user); // we do not mark the notification as unread by the user + + $this->entityManager->persist($notification); + $this->entityManager->flush(); + $notification->markAsReadBy($user); + $this->entityManager->flush(); + + $this->entityManager->refresh($notification); + + if (!$notification->isReadBy($user)) { + throw new \LogicException('Notification should be marked as read'); + } + + $notificationsIds = $this->repository->markAllNotificationAsUnreadForUser($user, [$notification->getId()]); + + self::assertContains($notification->getId(), $notificationsIds); + } +} diff --git a/src/Bundle/ChillMainBundle/chill.api.specs.yaml b/src/Bundle/ChillMainBundle/chill.api.specs.yaml index f37ee723d..1abdfc72e 100644 --- a/src/Bundle/ChillMainBundle/chill.api.specs.yaml +++ b/src/Bundle/ChillMainBundle/chill.api.specs.yaml @@ -5,8 +5,8 @@ info: title: "Chill api" description: "Api documentation for chill. Currently, work in progress" servers: - - url: "/api" - description: "Your current dev server" + - url: "/api" + description: "Your current dev server" components: schemas: @@ -165,7 +165,6 @@ components: endDate: $ref: "#/components/schemas/Date" - paths: /1.0/search.json: get: @@ -182,25 +181,25 @@ paths: The results are ordered by relevance, from the most to the lowest relevant. parameters: - - name: q - in: query - required: true - description: the pattern to search - schema: - type: string - - name: type[] - in: query - required: true - description: the type entities amongst the search is performed - schema: - type: array - items: - type: string - enum: - - person - - thirdparty - - user - - household + - name: q + in: query + required: true + description: the pattern to search + schema: + type: string + - name: type[] + in: query + required: true + description: the type entities amongst the search is performed + schema: + type: array + items: + type: string + enum: + - person + - thirdparty + - user + - household responses: 200: description: "OK" @@ -237,7 +236,7 @@ paths: minItems: 2 maxItems: 2 postcode: - $ref: '#/components/schemas/PostalCode' + $ref: "#/components/schemas/PostalCode" steps: type: string street: @@ -261,21 +260,21 @@ paths: - address summary: Return an address by id parameters: - - name: id - in: path - required: true - description: The address id - schema: - type: integer - format: integer - minimum: 1 + - name: id + in: path + required: true + description: The address id + schema: + type: integer + format: integer + minimum: 1 responses: 200: description: "ok" content: application/json: schema: - $ref: '#/components/schemas/Address' + $ref: "#/components/schemas/Address" 404: description: "not found" 401: @@ -285,14 +284,14 @@ paths: - address summary: patch an address parameters: - - name: id - in: path - required: true - description: The address id - schema: - type: integer - format: integer - minimum: 1 + - name: id + in: path + required: true + description: The address id + schema: + type: integer + format: integer + minimum: 1 requestBody: required: true content: @@ -321,7 +320,7 @@ paths: minItems: 2 maxItems: 2 postcode: - $ref: '#/components/schemas/PostalCode' + $ref: "#/components/schemas/PostalCode" steps: type: string street: @@ -344,28 +343,27 @@ paths: 400: description: "transition cannot be applyed" - /1.0/main/address/{id}/duplicate.json: post: tags: - address summary: Duplicate an existing address parameters: - - name: id - in: path - required: true - description: The address id that will be duplicated - schema: - type: integer - format: integer - minimum: 1 + - name: id + in: path + required: true + description: The address id that will be duplicated + schema: + type: integer + format: integer + minimum: 1 responses: 200: description: "ok" content: application/json: schema: - $ref: '#/components/schemas/Address' + $ref: "#/components/schemas/Address" 404: description: "not found" 401: @@ -377,12 +375,12 @@ paths: - address summary: Return a list of all reference addresses parameters: - - in: query - name: postal_code - required: false - schema: - type: integer - description: The id of a postal code to filter the reference addresses + - in: query + name: postal_code + required: false + schema: + type: integer + description: The id of a postal code to filter the reference addresses responses: 200: description: "ok" @@ -392,21 +390,21 @@ paths: - address summary: Return a reference address by id parameters: - - name: id - in: path - required: true - description: The reference address id - schema: - type: integer - format: integer - minimum: 1 + - name: id + in: path + required: true + description: The reference address id + schema: + type: integer + format: integer + minimum: 1 responses: 200: description: "ok" content: application/json: schema: - $ref: '#/components/schemas/AddressReference' + $ref: "#/components/schemas/AddressReference" 404: description: "not found" 401: @@ -419,27 +417,27 @@ paths: - search summary: Return a reference address by id parameters: - - name: id - in: path - required: true - description: The reference address id - schema: - type: integer - format: integer - minimum: 1 - - name: q - in: query - required: true - description: The search pattern - schema: - type: string + - name: id + in: path + required: true + description: The reference address id + schema: + type: integer + format: integer + minimum: 1 + - name: q + in: query + required: true + description: The search pattern + schema: + type: string responses: 200: description: "ok" content: application/json: schema: - $ref: '#/components/schemas/AddressReference' + $ref: "#/components/schemas/AddressReference" 404: description: "not found" 401: @@ -452,12 +450,12 @@ paths: - address summary: Return a list of all postal-code parameters: - - in: query - name: country - required: false - schema: - type: integer - description: The id of a country to filter the postal code + - in: query + name: country + required: false + schema: + type: integer + description: The id of a country to filter the postal code responses: 200: description: "ok" @@ -477,7 +475,7 @@ paths: code: type: string country: - $ref: '#/components/schemas/Country' + $ref: "#/components/schemas/Country" responses: 401: description: "Unauthorized" @@ -496,21 +494,21 @@ paths: - address summary: Return a postal code by id parameters: - - name: id - in: path - required: true - description: The postal code id - schema: - type: integer - format: integer - minimum: 1 + - name: id + in: path + required: true + description: The postal code id + schema: + type: integer + format: integer + minimum: 1 responses: 200: description: "ok" content: application/json: schema: - $ref: '#/components/schemas/PostalCode' + $ref: "#/components/schemas/PostalCode" 404: description: "not found" 401: @@ -523,25 +521,25 @@ paths: - search summary: Search a postal code parameters: - - name: q - in: query - required: true - description: The search pattern - schema: - type: string - - name: country - in: query - required: false - description: The country id - schema: - type: integer + - name: q + in: query + required: true + description: The search pattern + schema: + type: string + - name: country + in: query + required: false + description: The country id + schema: + type: integer responses: 200: description: "ok" content: application/json: schema: - $ref: '#/components/schemas/PostalCode' + $ref: "#/components/schemas/PostalCode" 404: description: "not found" 400: @@ -561,27 +559,26 @@ paths: - address summary: Return a country by id parameters: - - name: id - in: path - required: true - description: The country id - schema: - type: integer - format: integer - minimum: 1 + - name: id + in: path + required: true + description: The country id + schema: + type: integer + format: integer + minimum: 1 responses: 200: description: "ok" content: application/json: schema: - $ref: '#/components/schemas/Country' + $ref: "#/components/schemas/Country" 404: description: "not found" 401: description: "Unauthorized" - /1.0/main/user.json: get: tags: @@ -612,21 +609,21 @@ paths: - user summary: Return a user by id parameters: - - name: id - in: path - required: true - description: The user id - schema: - type: integer - format: integer - minimum: 1 + - name: id + in: path + required: true + description: The user id + schema: + type: integer + format: integer + minimum: 1 responses: 200: description: "ok" content: application/json: schema: - $ref: '#/components/schemas/User' + $ref: "#/components/schemas/User" 404: description: "not found" 401: @@ -649,14 +646,14 @@ paths: - scope summary: return a list of scopes parameters: - - name: id - in: path - required: true - description: The scope id - schema: - type: integer - format: integer - minimum: 1 + - name: id + in: path + required: true + description: The scope id + schema: + type: integer + format: integer + minimum: 1 responses: 200: description: "ok" @@ -724,14 +721,14 @@ paths: - location summary: Return the given location parameters: - - name: id - in: path - required: true - description: The location id - schema: - type: integer - format: integer - minimum: 1 + - name: id + in: path + required: true + description: The location id + schema: + type: integer + format: integer + minimum: 1 responses: 200: description: "ok" @@ -784,21 +781,21 @@ paths: id: 1 class: 'Chill\PersonBundle\Entity\AccompanyingPeriod' roles: - - 'CHILL_PERSON_ACCOMPANYING_PERIOD_SEE' + - "CHILL_PERSON_ACCOMPANYING_PERIOD_SEE" /1.0/main/notification/{id}/mark/read: post: tags: - notification summary: mark a notification as read parameters: - - name: id - in: path - required: true - description: The notification id - schema: - type: integer - format: integer - minimum: 1 + - name: id + in: path + required: true + description: The notification id + schema: + type: integer + format: integer + minimum: 1 responses: 202: description: "accepted" @@ -810,23 +807,55 @@ paths: - notification summary: mark a notification as unread parameters: - - name: id - in: path - required: true - description: The notification id - schema: - type: integer - format: integer - minimum: 1 + - name: id + in: path + required: true + description: The notification id + schema: + type: integer + format: integer + minimum: 1 responses: 202: description: "accepted" 403: description: "unauthorized" + /1.0/main/notification/mark/allread: + post: + tags: + - notification + summary: Mark all notifications as read + responses: + 202: + description: "accepted" + 403: + description: "unauthorized" + /1.0/main/notification/mark/undoallread: + post: # Use POST method for creating resources + tags: + - notification + summary: Mark notifications as unread + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + ids: + type: array + items: + type: integer + example: [1, 2, 3] # Example array of IDs + responses: + "202": + description: Notifications marked as unread successfully + "403": + description: Unauthorized /1.0/main/civility.json: get: tags: - - civility + - civility summary: Return all civility types responses: 200: @@ -844,7 +873,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/UserJob' + $ref: "#/components/schemas/UserJob" /1.0/main/workflow/my: get: tags: @@ -858,7 +887,7 @@ paths: schema: type: array items: - $ref: '#/components/schemas/Workflow' + $ref: "#/components/schemas/Workflow" 403: description: "Unauthorized" /1.0/main/workflow/my-cc: @@ -874,7 +903,7 @@ paths: schema: type: array items: - $ref: '#/components/schemas/Workflow' + $ref: "#/components/schemas/Workflow" 403: description: "Unauthorized" /1.0/main/dashboard-config-item.json: @@ -888,7 +917,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/DashboardConfigItem' + $ref: "#/components/schemas/DashboardConfigItem" 403: description: "Unauthorized" @@ -905,6 +934,6 @@ paths: schema: type: array items: - $ref: '#/components/schemas/NewsItem' + $ref: "#/components/schemas/NewsItem" 403: description: "Unauthorized" diff --git a/src/Bundle/ChillMainBundle/chill.webpack.config.js b/src/Bundle/ChillMainBundle/chill.webpack.config.js index 66731c7a4..35e88ed18 100644 --- a/src/Bundle/ChillMainBundle/chill.webpack.config.js +++ b/src/Bundle/ChillMainBundle/chill.webpack.config.js @@ -70,6 +70,7 @@ module.exports = function(encore, entries) encore.addEntry('mod_blur', __dirname + '/Resources/public/module/blur/index.js'); encore.addEntry('mod_input_address', __dirname + '/Resources/public/vuejs/Address/mod_input_address_index.js'); encore.addEntry('mod_notification_toggle_read_status', __dirname + '/Resources/public/module/notification/toggle_read.js'); + encore.addEntry('mod_notification_toggle_read_all_status', __dirname + '/Resources/public/module/notification/toggle_read_all.ts'); encore.addEntry('mod_pickentity_type', __dirname + '/Resources/public/module/pick-entity/index.js'); encore.addEntry('mod_entity_workflow_subscribe', __dirname + '/Resources/public/module/entity-workflow-subscribe/index.js'); encore.addEntry('mod_entity_workflow_pick', __dirname + '/Resources/public/module/entity-workflow-pick/index.js');