notification: store users which are unread instead of read, and in

dedicated table

The query "which are the unread notification" is much more frequent than
the read one. We then store the unread items in a dedicated table.
This commit is contained in:
Julien Fastré 2021-12-26 01:00:50 +01:00
parent f6f0786d38
commit bd3919efcb
9 changed files with 283 additions and 79 deletions

View File

@ -11,7 +11,6 @@
"php": "^7.4",
"champs-libres/async-uploader-bundle": "dev-sf4#d57134aee8e504a83c902ff0cf9f8d36ac418290",
"champs-libres/wopi-bundle": "dev-master#59b468503b9413f8d588ef9e626e7675560db3d8",
"ocramius/package-versions": "^1.10",
"doctrine/doctrine-bundle": "^2.1",
"doctrine/doctrine-migrations-bundle": "^3.0",
"doctrine/orm": "^2.7",
@ -22,6 +21,7 @@
"knplabs/knp-time-bundle": "^1.12",
"league/csv": "^9.7.1",
"nyholm/psr7": "^1.4",
"ocramius/package-versions": "^1.10",
"phpoffice/phpspreadsheet": "^1.16",
"ramsey/uuid-doctrine": "^1.7",
"sensio/framework-extra-bundle": "^5.5",
@ -47,6 +47,7 @@
"twig/extra-bundle": "^3.0",
"twig/intl-extra": "^3.0",
"twig/markdown-extra": "^3.3",
"twig/string-extra": "^3.3",
"twig/twig": "^3.0"
},
"conflict": {
@ -72,7 +73,13 @@
"bin-dir": "bin",
"optimize-autoloader": true,
"sort-packages": true,
"vendor-dir": "tests/app/vendor"
"vendor-dir": "tests/app/vendor",
"allow-plugins": {
"composer/package-versions-deprecated": true,
"phpstan/extension-installer": true,
"ergebnis/composer-normalize": true,
"phpro/grumphp": true
}
},
"autoload": {
"psr-4": {

View File

@ -118,9 +118,9 @@ class NotificationController extends AbstractController
}
/**
* @Route("/my", name="chill_main_notification_my")
* @Route("/inbox", name="chill_main_notification_my")
*/
public function myAction(): Response
public function inboxAction(): Response
{
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED');
$currentUser = $this->security->getUser();
@ -134,21 +134,61 @@ class NotificationController extends AbstractController
$offset = $paginator->getCurrentPage()->getFirstItemNumber()
);
return $this->render('@ChillMain/Notification/list.html.twig', [
'datas' => $this->itemsForTemplate($notifications),
'notifications' => $notifications,
'paginator' => $paginator,
'step' => 'inbox',
'unreads' => $this->countUnread(),
]);
}
/**
* @Route("/sent", name="chill_main_notification_sent")
*/
public function sentAction(): Response
{
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED');
$currentUser = $this->security->getUser();
$notificationsNbr = $this->notificationRepository->countAllForSender($currentUser);
$paginator = $this->paginatorFactory->create($notificationsNbr);
$notifications = $this->notificationRepository->findAllForSender(
$currentUser,
$limit = $paginator->getItemsPerPage(),
$offset = $paginator->getCurrentPage()->getFirstItemNumber()
);
return $this->render('@ChillMain/Notification/list.html.twig', [
'datas' => $this->itemsForTemplate($notifications),
'notifications' => $notifications,
'paginator' => $paginator,
'step' => 'sent',
'unreads' => $this->countUnread(),
]);
}
private function countUnread(): array
{
return [
'sent' => $this->notificationRepository->countUnreadByUserWhereSender($this->security->getUser()),
'inbox' => $this->notificationRepository->countUnreadByUserWhereAddressee($this->security->getUser()),
];
}
private function itemsForTemplate(array $notifications): array
{
$templateData = [];
foreach ($notifications as $notification) {
$data = [
$templateData[] = [
'template' => $this->notificationHandlerManager->getTemplate($notification),
'template_data' => $this->notificationHandlerManager->getTemplateData($notification),
'notification' => $notification,
];
$templateData[] = $data;
}
return $this->render('@ChillMain/Notification/show.html.twig', [
'datas' => $templateData,
'notifications' => $notifications,
'paginator' => $paginator,
]);
return $templateData;
}
}

View File

@ -50,11 +50,6 @@ class Notification
*/
private string $message = '';
/**
* @ORM\Column(type="json")
*/
private array $read = [];
/**
* @ORM\Column(type="string", length=255)
*/
@ -71,9 +66,16 @@ class Notification
*/
private User $sender;
/**
* @ORM\ManyToMany(targetEntity=User::class)
* @ORM\JoinTable(name="chill_main_notification_addresses_unread")
*/
private Collection $unreadBy;
public function __construct()
{
$this->addressees = new ArrayCollection();
$this->unreadBy = new ArrayCollection();
$this->setDate(new DateTimeImmutable());
}
@ -81,6 +83,16 @@ class Notification
{
if (!$this->addressees->contains($addressee)) {
$this->addressees[] = $addressee;
$this->addUnreadBy($addressee);
}
return $this;
}
public function addUnreadBy(User $user): self
{
if (!$this->unreadBy->contains($user)) {
$this->unreadBy->add($user);
}
return $this;
@ -109,11 +121,6 @@ class Notification
return $this->message;
}
public function getRead(): array
{
return $this->read;
}
public function getRelatedEntityClass(): ?string
{
return $this->relatedEntityClass;
@ -129,9 +136,32 @@ class Notification
return $this->sender;
}
public function isReadBy(User $user): bool
{
return !$this->unreadBy->contains($user);
}
public function markAsReadBy(User $user): self
{
return $this->removeUnreadBy($user);
}
public function markAsUnreadBy(User $user): self
{
return $this->addUnreadBy($user);
}
public function removeAddressee(User $addressee): self
{
$this->addressees->removeElement($addressee);
$this->unreadBy->removeElement($addressee);
return $this;
}
public function removeUnreadBy(User $user): self
{
$this->unreadBy->removeElement($user);
return $this;
}
@ -150,13 +180,6 @@ class Notification
return $this;
}
public function setRead(array $read): self
{
$this->read = $read;
return $this;
}
public function setRelatedEntityClass(string $relatedEntityClass): self
{
$this->relatedEntityClass = $relatedEntityClass;

View File

@ -13,25 +13,75 @@ namespace Chill\MainBundle\Repository;
use Chill\MainBundle\Entity\Notification;
use Chill\MainBundle\Entity\User;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Query;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ObjectRepository;
final class NotificationRepository implements ObjectRepository
{
private EntityManagerInterface $em;
private EntityRepository $repository;
public function __construct(EntityManagerInterface $entityManager)
{
$this->em = $entityManager;
$this->repository = $entityManager->getRepository(Notification::class);
}
public function countAllForAttendee(User $addressee): int // TODO passer à attendees avec S
public function countAllForAttendee(User $addressee): int
{
$query = $this->queryAllForAttendee($addressee, $countQuery = true);
return $this->queryByAddressee($addressee)
->select('count(n)')
->getQuery()
->getSingleScalarResult();
}
return $query->getSingleScalarResult();
public function countAllForSender(User $sender): int
{
return $this->queryBySender($sender)
->select('count(n)')
->getQuery()
->getSingleScalarResult();
}
public function countUnreadByUser(User $user): int
{
$sql = 'SELECT count(*) AS c FROM chill_main_notification_addresses_unread WHERE user_id = ?';
$rsm = new Query\ResultSetMapping();
$rsm->addScalarResult('c', 'c', Types::INTEGER);
$nq = $this->em->createNativeQuery($sql, $rsm);
return $nq->getSingleScalarResult();
}
public function countUnreadByUserWhereAddressee(User $user): int
{
$qb = $this->repository->createQueryBuilder('n');
$qb
->select('count(n)')
->where($qb->expr()->isMemberOf(':user', 'n.addressees'))
->andWhere($qb->expr()->isMemberOf(':user', 'n.unreadBy'))
->setParameter('user', $user);
return $qb->getQuery()->getSingleScalarResult();
}
public function countUnreadByUserWhereSender(User $user): int
{
$qb = $this->repository->createQueryBuilder('n');
$qb
->select('count(n)')
->where($qb->expr()->eq('n.sender', ':user'))
->andWhere($qb->expr()->isMemberOf(':user', 'n.unreadBy'))
->setParameter('user', $user);
return $qb->getQuery()->getSingleScalarResult();
}
public function find($id, $lockMode = null, $lockVersion = null): ?Notification
@ -53,9 +103,9 @@ final class NotificationRepository implements ObjectRepository
*
* @return Notification[]
*/
public function findAllForAttendee(User $addressee, $limit = null, $offset = null): array // TODO passer à attendees avec S
public function findAllForAttendee(User $addressee, $limit = null, $offset = null): array
{
$query = $this->queryAllForAttendee($addressee);
$query = $this->queryByAddressee($addressee)->select('n');
if ($limit) {
$query = $query->setMaxResults($limit);
@ -65,7 +115,22 @@ final class NotificationRepository implements ObjectRepository
$query = $query->setFirstResult($offset);
}
return $query->getResult();
return $query->getQuery()->getResult();
}
public function findAllForSender(User $sender, $limit = null, $offset = null): array
{
$query = $this->queryBySender($sender)->select('n');
if ($limit) {
$query = $query->setMaxResults($limit);
}
if ($offset) {
$query = $query->setFirstResult($offset);
}
return $query->getQuery()->getResult();
}
/**
@ -89,22 +154,25 @@ final class NotificationRepository implements ObjectRepository
return Notification::class;
}
private function queryAllForAttendee(User $addressee, bool $countQuery = false): Query
private function queryByAddressee(User $addressee, bool $countQuery = false): QueryBuilder
{
$qb = $this->repository->createQueryBuilder('n');
$select = 'n';
if ($countQuery) {
$select = 'count(n)';
}
$qb
->select($select)
->join('n.addressees', 'a')
->where('a = :addressee')
->where($qb->expr()->isMemberOf(':addressee', 'n.addressees'))
->setParameter('addressee', $addressee);
return $qb->getQuery();
return $qb;
}
private function queryBySender(User $sender): QueryBuilder
{
$qb = $this->repository->createQueryBuilder('n');
$qb
->where($qb->expr()->eq('n.sender', ':sender'))
->setParameter('sender', $sender);
return $qb;
}
}

View File

@ -1,43 +1,65 @@
{% extends "@ChillMain/layout.html.twig" %}
{% block title 'notification.List'|trans %}
{% block content %}
<div id="container content">
<div class="grid-8 centered">
<h1>{{ "Notifications list" | trans }}</h1>
<div class="row">
<div class="grid-8 centered">
<h1>{{ block('title') }}</h1>
{%for data in datas %}
{% set notification = data.notification %}
<ul class="nav nav-tabs">
<li class="nav-item">
<a class="nav-link {% if step == 'inbox' %}active{% endif %}" href="{{ path('chill_main_notification_my') }}">
{{ 'notification.Notifications received'|trans }}
<span class="badge rounded-pill bg-danger">
{{ unreads['inbox'] }}
</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if step == 'sent' %}active{% endif %}" href="{{ path('chill_main_notification_sent') }}">
{{ 'notification.Notifications sent'|trans }}
{% if unreads['sent'] > 0 %}
<span class="badge rounded-pill bg-danger">
{{ unreads['sent'] }}
</span>
{% endif %}
</a>
</li>
</ul>
<dl class="chill_view_data">
<dt class="inline">{{ 'Message'|trans }}</dt>
<dd>{{ notification.message }}</dd>
</dl>
{% if datas|length == 0 %}
{% if step == 'inbox' %}
<p class="chill-no-data-statement">{{ 'notification.Any notification received'|trans }}</p>
{% else %}
<p class="chill-no-data-statement">{{ 'notification.Any notification sent'|trans }}</p>
{% endif %}
{% else %}
<div class="flex-table">
{% for data in datas %}
{% set notification = data.notification %}
<div class="item-row">
{% include data.template with data.template_data %}
</div>
<div class="item-row separator">
{% if step == 'inbox' %}
<div>{{ 'notification.from'|trans }}: {{ notification.sender|chill_entity_render_string }}</div>
{% endif %}
<div>{{ 'notification.adressees'|trans }}{% for a in notification.addressees %}{{ a|chill_entity_render_string }}{% if not loop.last %}, {% endif %}{% endfor %}</div>
<div>{{ notification.date|format_datetime('long', 'short') }}</div>
<div>
<blockquote class="chill-user-quote">
{{ notification.message|u.truncate(250, '…', false)|chill_markdown_to_html }}
</blockquote>
</div>
</div>
{% endfor %}
<dl class="chill_view_data">
<dt class="inline">{{ 'Date'|trans }}</dt>
<dd>{{ notification.date | date('long') }}</dd>
</dl>
</div>
{% endif %}
</div>
<dl class="chill_view_data">
<dt class="inline">{{ 'Sender'|trans }}</dt>
<dd>{{ notification.sender }}</dd>
</dl>
<dl class="chill_view_data">
<dt class="inline">{{ 'Addressees'|trans }}</dt>
<dd>{{ notification.addressees |join(', ') }}</dd>
</dl>
<dl class="chill_view_data">
<dt class="inline">{{ 'Entity'|trans }}</dt>
<dd>
{% include data.template with data.template_data %}
</dd>
</dl>
{% else %}
<p class="chill-no-data-statement">{{ notification.Any notification received }}</p>
{% endfor %}
</div>
</div>
{% endblock content %}

View File

@ -0,0 +1,39 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\Migrations\Main;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20211225231532 extends AbstractMigration
{
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE chill_main_notification_addresses_unread');
$this->addSql('ALTER TABLE chill_main_notification ADD read JSONB DEFAULT \'[]\'');
}
public function getDescription(): string
{
return 'Store notification readed by user in a specific table';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE chill_main_notification_addresses_unread (notification_id INT NOT NULL, user_id INT NOT NULL, PRIMARY KEY(notification_id, user_id))');
$this->addSql('CREATE INDEX IDX_154A075FEF1A9D84 ON chill_main_notification_addresses_unread (notification_id)');
$this->addSql('CREATE INDEX IDX_154A075FA76ED395 ON chill_main_notification_addresses_unread (user_id)');
$this->addSql('ALTER TABLE chill_main_notification_addresses_unread ADD CONSTRAINT FK_154A075FEF1A9D84 FOREIGN KEY (notification_id) REFERENCES chill_main_notification (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE chill_main_notification_addresses_unread ADD CONSTRAINT FK_154A075FA76ED395 FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE chill_main_notification DROP read');
}
}

View File

@ -354,4 +354,8 @@ Created by: Créé par
notification:
Notify: Notifier
Notification create: Notification envoyée
Notification created: Notification envoyée
Any notification received: Aucune notification reçue
Any notification sent: Aucune notification envoyée
Notifications received: Notifications reçues
Notifications sent: Notification envoyées

View File

@ -187,7 +187,7 @@ class HouseholdMemberController extends ApiController
$_format,
['groups' => ['read']]
);
} catch (Exception\InvalidArgumentException | Exception\UnexpectedValueException $e) {
} catch (Exception\InvalidArgumentException|Exception\UnexpectedValueException $e) {
throw new BadRequestException("Deserialization error: {$e->getMessage()}", 45896, $e);
}

View File

@ -1,3 +1,4 @@
services:
Chill\PersonBundle\Notification\AccompanyingPeriodNotificationRenderer:
autowire: true
autoconfigure: true