add dynamic notification with mercure

This commit is contained in:
Charles 2025-11-17 16:11:35 +01:00
parent 3744d81035
commit 9dc97c5843
20 changed files with 1519 additions and 37 deletions

View File

@ -14,6 +14,7 @@ import 'choices.js/public/assets/styles/choices.min.css';
import 'tabulator-tables/dist/css/tabulator.min.css';
import './styles/tabulator.css';
import './styles/card.css';
import './styles/notifications.css';
import 'bootstrap';
import './js/template.js';

View File

@ -0,0 +1,332 @@
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static targets = ['badge', 'list'];
static values = {
userId: Number,
mercureUrl: String
};
connect() {
this.loadNotifications();
this.connectToMercure();
this.toastContainer = this.createToastContainer();
}
disconnect() {
if (this.eventSource) {
this.eventSource.close();
}
}
async loadNotifications() {
try {
const response = await fetch('/notifications/unread');
const data = await response.json();
this.updateBadge(data.unreadCount);
this.renderNotifications(data.notifications);
} catch (error) {
console.error('Failed to load notifications:', error);
}
}
async connectToMercure() {
try {
// Fetch the JWT token and topic from the server
const response = await fetch('/notifications/mercure-token');
const data = await response.json();
console.log('Mercure token data:', data);
// Use server-provided topic if available, otherwise fallback to default per-user topic
const topic = data.topic || `http://portail.solutions-easy.moi/notifications/user/${this.userIdValue}`;
const url = new URL(this.mercureUrlValue);
url.searchParams.append('topic', topic);
// Add authorization token as URL param if provided (Mercure can accept it this way)
if (data.token) {
url.searchParams.append('authorization', data.token);
}
console.log('Connecting to Mercure...');
console.log('Mercure URL:', this.mercureUrlValue);
console.log('Topic:', topic);
console.log('Full URL:', url.toString());
try {
this.eventSource = new EventSource(url.toString());
} catch (e) {
console.error('❌ Failed to create EventSource:', e);
return;
}
this.eventSource.onopen = () => {
console.log('✅ Mercure connection established successfully!');
};
this.eventSource.onmessage = (event) => {
console.log('📨 New notification received:', event.data);
try {
const notification = JSON.parse(event.data);
this.handleNewNotification(notification);
} catch (parseError) {
console.error('Failed to parse Mercure message data:', parseError, 'raw data:', event.data);
}
};
this.eventSource.onerror = (error) => {
console.error('❌ Mercure connection error:', error);
try {
console.error('EventSource readyState:', this.eventSource.readyState);
} catch (e) {
console.error('Could not read EventSource.readyState:', e);
}
// EventSource will automatically try to reconnect.
// If closed, log it for debugging.
try {
if (this.eventSource.readyState === EventSource.CLOSED) {
console.log('Connection closed. Will retry...');
} else if (this.eventSource.readyState === EventSource.CONNECTING) {
console.log('Connection is reconnecting (CONNECTING).');
} else if (this.eventSource.readyState === EventSource.OPEN) {
console.log('Connection is open (OPEN).');
}
} catch (e) {
console.error('Error while checking EventSource state:', e);
}
};
console.log('EventSource connected to:', url.toString());
} catch (error) {
console.error('Failed to connect to Mercure:', error);
}
}
handleNewNotification(notification) {
this.showToast(notification);
this.loadNotifications();
}
updateBadge(count) {
const badge = this.badgeTarget;
if (count > 0) {
badge.textContent = count > 99 ? '99+' : count;
badge.style.display = 'block';
} else {
badge.style.display = 'none';
}
}
renderNotifications(notifications) {
const list = this.listTarget;
if (notifications.length === 0) {
list.innerHTML = `
<div class="text-center py-4 text-muted">
<i class="mx-0 mb-2">
<svg xmlns="http://www.w3.org/2000/svg" width="30" height="30" fill="currentColor" viewBox="0 0 16 16">
<path d="M8.865 2.5a.75.75 0 0 0-1.73 0L6.5 5.5H4.75a.75.75 0 0 0 0 1.5h1.5l-.5 3H3.5a.75.75 0 0 0 0 1.5h1.5l-.635 3.135a.75.75 0 0 0 1.47.28L6.5 11.5h3l-.635 3.135a.75.75 0 0 0 1.47.28L11 11.5h1.75a.75.75 0 0 0 0-1.5h-1.5l.5-3h2.25a.75.75 0 0 0 0-1.5h-2l.635-3.135zM9.5 10l.5-3h-3l-.5 3h3z"/>
</svg>
</i>
<p class="mb-0">Aucune notification</p>
</div>
`;
return;
}
list.innerHTML = notifications.map(notif => this.renderNotificationItem(notif)).join('');
}
renderNotificationItem(notification) {
const iconHtml = this.getIcon(notification.type);
const timeAgo = this.getTimeAgo(notification.createdAt);
const readClass = notification.isRead ? 'opacity-75' : '';
return `
<a class="dropdown-item preview-item ${readClass}"
href="#"
data-notification-id="${notification.id}"
data-action="click->notification#markAsRead">
<div class="preview-thumbnail">
<div class="preview-icon ${this.getIconBgClass(notification.type)}">
${iconHtml}
</div>
</div>
<div class="preview-item-content">
<h6 class="preview-subject font-weight-normal mb-1">${this.escapeHtml(notification.title)}</h6>
<p class="font-weight-light small-text mb-0 text-muted">${this.escapeHtml(notification.message)}</p>
<p class="font-weight-light small-text mb-0 text-muted mt-1">
<small>${timeAgo}</small>
</p>
</div>
<button class="btn btn-sm btn-link text-danger ms-2"
data-action="click->notification#deleteNotification"
data-notification-id="${notification.id}"
style="padding: 0.25rem;">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
</svg>
</button>
</a>
`;
}
getIcon(type) {
const icons = {
user_joined: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="mx-0 mb-1" viewBox="0 0 16 16"><path d="M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm2-3a2 2 0 1 1-4 0 2 2 0 0 1 4 0zm4 8c0 1-1 1-1 1H3s-1 0-1-1 1-4 6-4 6 3 6 4zm-1-.004c-.001-.246-.154-.986-.832-1.664C11.516 10.68 10.289 10 8 10c-2.29 0-3.516.68-4.168 1.332-.678.678-.83 1.418-.832 1.664h10z"/></svg>',
user_invited: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="mx-0 mb-1" viewBox="0 0 16 16"><path d="M6 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm-5 6s-1 0-1-1 1-4 6-4 6 3 6 4-1 1-1 1H1zM11 3.5a.5.5 0 0 1 .5-.5h4a.5.5 0 0 1 0 1h-4a.5.5 0 0 1-.5-.5zm.5 2.5a.5.5 0 0 0 0 1h4a.5.5 0 0 0 0-1h-4zm2 3a.5.5 0 0 0 0 1h2a.5.5 0 0 0 0-1h-2z"/></svg>',
user_accepted: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="mx-0 mb-1" viewBox="0 0 16 16"><path d="M10.97 4.97a.75.75 0 0 1 1.07 1.05l-3.99 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425a.267.267 0 0 1 .02-.022z"/></svg>',
user_removed: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="mx-0 mb-1" viewBox="0 0 16 16"><path d="M6 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm-5 6s-1 0-1-1 1-4 6-4 6 3 6 4-1 1-1 1H1zM11 3.5a.5.5 0 0 1 .5-.5h4a.5.5 0 0 1 0 1h-4a.5.5 0 0 1-.5-.5z"/></svg>',
org_update: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="mx-0 mb-1" viewBox="0 0 16 16"><path d="M8 4.754a3.246 3.246 0 1 0 0 6.492 3.246 3.246 0 0 0 0-6.492zM5.754 8a2.246 2.246 0 1 1 4.492 0 2.246 2.246 0 0 1-4.492 0z"/><path d="M9.796 1.343c-.527-1.79-3.065-1.79-3.592 0l-.094.319a.873.873 0 0 1-1.255.52l-.292-.16c-1.64-.892-3.433.902-2.54 2.541l.159.292a.873.873 0 0 1-.52 1.255l-.319.094c-1.79.527-1.79 3.065 0 3.592l.319.094a.873.873 0 0 1 .52 1.255l-.16.292c-.892 1.64.901 3.434 2.541 2.54l.292-.159a.873.873 0 0 1 1.255.52l.094.319c.527 1.79 3.065 1.79 3.592 0l.094-.319a.873.873 0 0 1 1.255-.52l.292.16c1.64.893 3.434-.902 2.54-2.541l-.159-.292a.873.873 0 0 1 .52-1.255l.319-.094c1.79-.527 1.79-3.065 0-3.592l-.319-.094a.873.873 0 0 1-.52-1.255l.16-.292c.893-1.64-.902-3.433-2.541-2.54l-.292.159a.873.873 0 0 1-1.255-.52l-.094-.319z"/></svg>',
app_access: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="mx-0 mb-1" viewBox="0 0 16 16"><path d="M8 1a2 2 0 0 1 2 2v4H6V3a2 2 0 0 1 2-2zm3 6V3a3 3 0 0 0-6 0v4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z"/></svg>',
role_changed: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="mx-0 mb-1" viewBox="0 0 16 16"><path d="M9.405 1.05c-.413-1.4-2.397-1.4-2.81 0l-.1.34a1.464 1.464 0 0 1-2.105.872l-.31-.17c-1.283-.698-2.686.705-1.987 1.987l.169.311c.446.82.023 1.841-.872 2.105l-.34.1c-1.4.413-1.4 2.397 0 2.81l.34.1a1.464 1.464 0 0 1 .872 2.105l-.17.31c-.698 1.283.705 2.686 1.987 1.987l.311-.169a1.464 1.464 0 0 1 2.105.872l.1.34c.413 1.4 2.397 1.4 2.81 0l.1-.34a1.464 1.464 0 0 1 2.105-.872l.31.17c1.283.698 2.686-.705 1.987-1.987l-.169-.311a1.464 1.464 0 0 1 .872-2.105l.34-.1c1.4-.413 1.4-2.397 0-2.81l-.34-.1a1.464 1.464 0 0 1-.872-2.105l.17-.31c.698-1.283-.705-2.686-1.987-1.987l-.311.169a1.464 1.464 0 0 1-2.105-.872l-.1-.34zM8 10.93a2.929 2.929 0 1 1 0-5.86 2.929 2.929 0 0 1 0 5.858z"/></svg>',
};
return icons[type] || icons.user_joined;
}
getIconBgClass(type) {
const classes = {
user_joined: 'bg-success',
user_invited: 'bg-info',
user_accepted: 'bg-success',
user_removed: 'bg-danger',
org_update: 'bg-warning',
app_access: 'bg-primary',
role_changed: 'bg-info',
};
return classes[type] || 'bg-primary';
}
getTimeAgo(dateString) {
if (!dateString) return '';
const date = new Date(dateString);
if (isNaN(date)) return '';
const now = new Date();
const seconds = Math.floor((now - date) / 1000);
if (seconds < 60) return 'À l\'instant';
if (seconds < 3600) {
const mins = Math.floor(seconds / 60);
return `Il y a ${mins} ${mins > 1 ? 'mins' : 'min'}`;
}
if (seconds < 86400) {
const hours = Math.floor(seconds / 3600);
return `Il y a ${hours} ${hours > 1 ? 'h' : 'h'}`;
}
if (seconds < 604800) {
const days = Math.floor(seconds / 86400);
return `Il y a ${days} ${days > 1 ? 'j' : 'j'}`;
}
// For older dates, show a localized date string
try {
return date.toLocaleDateString('fr-FR', {year: 'numeric', month: 'short', day: 'numeric'});
} catch (e) {
return date.toLocaleDateString();
}
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
async markAsRead(event) {
event.preventDefault();
event.stopPropagation();
const notificationId = event.currentTarget.dataset.notificationId;
try {
await fetch(`/notifications/${notificationId}/read`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
});
this.loadNotifications();
} catch (error) {
console.error('Failed to mark notification as read:', error);
}
}
async markAllAsRead(event) {
event.preventDefault();
event.stopPropagation();
try {
await fetch('/notifications/mark-all-read', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
});
this.loadNotifications();
} catch (error) {
console.error('Failed to mark all as read:', error);
}
}
async deleteNotification(event) {
event.preventDefault();
event.stopPropagation();
const notificationId = event.currentTarget.dataset.notificationId;
try {
await fetch(`/notifications/${notificationId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
}
});
this.loadNotifications();
} catch (error) {
console.error('Failed to delete notification:', error);
}
}
markDropdownAsRead(event) {
}
createToastContainer() {
let container = document.getElementById('notification-toast-container');
if (!container) {
container = document.createElement('div');
container.id = 'notification-toast-container';
container.className = 'notification-toast-container';
document.body.appendChild(container);
}
return container;
}
showToast(notification) {
const toast = document.createElement('div');
toast.className = 'notification-toast';
toast.innerHTML = `
<div class="notification-toast-icon ${this.getIconBgClass(notification.type)}">
${this.getIcon(notification.type)}
</div>
<div class="notification-toast-content">
<div class="notification-toast-title">${this.escapeHtml(notification.title)}</div>
<div class="notification-toast-message">${this.escapeHtml(notification.message)}</div>
</div>
<button class="notification-toast-close" onclick="this.parentElement.remove()">×</button>
`;
this.toastContainer.appendChild(toast);
setTimeout(() => toast.classList.add('show'), 10);
setTimeout(() => {
toast.classList.remove('show');
setTimeout(() => toast.remove(), 300);
}, 5000);
}
}

View File

@ -0,0 +1,156 @@
.notification-toast-container {
position: fixed;
top: 80px;
right: 20px;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 10px;
pointer-events: none;
}
.notification-toast {
background: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
padding: 16px;
min-width: 320px;
max-width: 400px;
display: flex;
align-items: flex-start;
gap: 12px;
opacity: 0;
transform: translateX(400px);
transition: all 0.3s ease;
pointer-events: all;
}
.notification-toast.show {
opacity: 1;
transform: translateX(0);
}
.notification-toast-icon {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: white;
}
.notification-toast-icon svg {
width: 20px;
height: 20px;
}
.notification-toast-content {
flex: 1;
min-width: 0;
}
.notification-toast-title {
font-weight: 600;
font-size: 14px;
margin-bottom: 4px;
color: #333;
}
.notification-toast-message {
font-size: 13px;
color: #666;
line-height: 1.4;
}
.notification-toast-close {
background: none;
border: none;
font-size: 24px;
line-height: 1;
color: #999;
cursor: pointer;
padding: 0;
width: 24px;
height: 24px;
flex-shrink: 0;
transition: color 0.2s;
}
.notification-toast-close:hover {
color: #333;
}
.nav-notif .count-notification {
position: absolute;
top: 15px;
right: -5px;
background: var(--primary-blue-light);
color: white;
border-radius: 12px;
padding: 3px 7px;
font-size: 8px;
font-weight: bold;
min-width: 5px;
height: 10px;
line-height:0.5;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.dropdown-item.preview-item {
display: flex;
align-items: flex-start;
padding: 12px 16px;
border-bottom: 1px solid #f0f0f0;
transition: background-color 0.2s;
text-decoration: none;
color: inherit;
}
.dropdown-item.preview-item:hover {
background-color: #f8f9fa;
}
.dropdown-item.preview-item:last-child {
border-bottom: none;
}
.preview-thumbnail {
margin-right: 12px;
}
.preview-icon {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
}
.preview-icon svg,
.preview-icon i {
width: 20px;
height: 20px;
}
.preview-item-content {
flex: 1;
min-width: 0;
}
.preview-subject {
font-size: 14px;
font-weight: 600;
margin-bottom: 4px;
color: #333;
}
.preview-item-content p {
font-size: 12px;
margin-bottom: 0;
line-height: 1.4;
}

View File

@ -6,3 +6,4 @@ mercure:
jwt:
secret: '%env(MERCURE_JWT_SECRET)%'
publish: '*'
subscribe: '*'

View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20251029104354 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE "user" ADD password_token VARCHAR(255) DEFAULT NULL');
$this->addSql('ALTER TABLE "user" ADD token_expiry TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
$this->addSql('COMMENT ON COLUMN "user".token_expiry IS \'(DC2Type:datetime_immutable)\'');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
$this->addSql('ALTER TABLE "user" DROP password_token');
$this->addSql('ALTER TABLE "user" DROP token_expiry');
}
}

View File

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20251029104801 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE "user" ALTER password DROP NOT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
$this->addSql('ALTER TABLE "user" ALTER password SET NOT NULL');
}
}

View File

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20251104081124 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE apps ADD logo_mini_url VARCHAR(255) DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
$this->addSql('ALTER TABLE apps DROP logo_mini_url');
}
}

View File

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20251105083809 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE users_organizations ADD modified_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
$this->addSql('COMMENT ON COLUMN users_organizations.modified_at IS \'(DC2Type:datetime_immutable)\'');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
$this->addSql('ALTER TABLE users_organizations DROP modified_at');
}
}

View File

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20251117104819 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE notifications (id SERIAL NOT NULL, user_id INT NOT NULL, organization_id INT DEFAULT NULL, type VARCHAR(50) NOT NULL, title VARCHAR(255) NOT NULL, message TEXT NOT NULL, data JSON DEFAULT NULL, is_read BOOLEAN DEFAULT false NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, read_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE INDEX IDX_6000B0D3A76ED395 ON notifications (user_id)');
$this->addSql('CREATE INDEX IDX_6000B0D332C8A3DE ON notifications (organization_id)');
$this->addSql('CREATE INDEX idx_user_read_created ON notifications (user_id, is_read, created_at)');
$this->addSql('COMMENT ON COLUMN notifications.created_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN notifications.read_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('ALTER TABLE notifications ADD CONSTRAINT FK_6000B0D3A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE notifications ADD CONSTRAINT FK_6000B0D332C8A3DE FOREIGN KEY (organization_id) REFERENCES organizations (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
$this->addSql('ALTER TABLE notifications DROP CONSTRAINT FK_6000B0D3A76ED395');
$this->addSql('ALTER TABLE notifications DROP CONSTRAINT FK_6000B0D332C8A3DE');
$this->addSql('DROP TABLE notifications');
}
}

View File

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20251117125146 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
}
}

View File

@ -0,0 +1,144 @@
<?php
namespace App\Controller;
use App\Entity\Notification;
use App\Repository\NotificationRepository;
use App\Service\UserService;
use Doctrine\ORM\EntityManagerInterface;
use Lcobucci\JWT\Configuration;
use Lcobucci\JWT\Signer\Hmac\Sha256;
use Lcobucci\JWT\Signer\Key\InMemory;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
#[Route(path: '/notifications', name: 'notification_')]
class NotificationController extends AbstractController
{
public function __construct(
private readonly NotificationRepository $notificationRepository,
private readonly EntityManagerInterface $entityManager,
private readonly UserService $userService
) {
}
#[Route(path: '/', name: 'index', methods: ['GET'])]
public function index(): JsonResponse
{
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
$user = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
$notifications = $this->notificationRepository->findRecentByUser($user, 50);
$unreadCount = $this->notificationRepository->countUnreadByUser($user);
return new JsonResponse([
'notifications' => array_map(fn($n) => $n->toArray(), $notifications),
'unreadCount' => $unreadCount,
]);
}
#[Route(path: '/unread', name: 'unread', methods: ['GET'])]
public function unread(): JsonResponse
{
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
$user = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
$notifications = $this->notificationRepository->findUnreadByUser($user);
$unreadCount = count($notifications);
return new JsonResponse([
'notifications' => array_map(fn($n) => $n->toArray(), $notifications),
'unreadCount' => $unreadCount,
]);
}
#[Route(path: '/count', name: 'count', methods: ['GET'])]
public function count(): JsonResponse
{
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
$user = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
$unreadCount = $this->notificationRepository->countUnreadByUser($user);
return new JsonResponse(['count' => $unreadCount]);
}
#[Route(path: '/{id}/read', name: 'mark_read', methods: ['POST'])]
public function markAsRead(int $id): JsonResponse
{
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
$user = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
$notification = $this->notificationRepository->find($id);
if (!$notification || $notification->getUser()->getId() !== $user->getId()) {
return new JsonResponse(['error' => 'Notification not found'], Response::HTTP_NOT_FOUND);
}
$notification->setIsRead(true);
$this->entityManager->flush();
return new JsonResponse(['success' => true]);
}
#[Route(path: '/mark-all-read', name: 'mark_all_read', methods: ['POST'])]
public function markAllAsRead(): JsonResponse
{
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
$user = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
$count = $this->notificationRepository->markAllAsReadForUser($user);
return new JsonResponse(['success' => true, 'count' => $count]);
}
#[Route(path: '/{id}', name: 'delete', methods: ['DELETE'])]
public function delete(int $id): JsonResponse
{
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
$user = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
$notification = $this->notificationRepository->find($id);
if (!$notification || $notification->getUser()->getId() !== $user->getId()) {
return new JsonResponse(['error' => 'Notification not found'], Response::HTTP_NOT_FOUND);
}
$this->entityManager->remove($notification);
$this->entityManager->flush();
return new JsonResponse(['success' => true]);
}
#[Route(path: '/mercure-token', name: 'mercure_token', methods: ['GET'])]
public function getMercureToken(): JsonResponse
{
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
$user = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
$topic = sprintf('http://portail.solutions-easy.moi/notifications/user/%d', $user->getId());
// Generate JWT token for Mercure subscription
$secret = $_ENV['MERCURE_JWT_SECRET'];
$config = Configuration::forSymmetricSigner(
new Sha256(),
InMemory::plainText($secret)
);
$token = $config->builder()
->withClaim('mercure', [
'subscribe' => [$topic]
])
->getToken($config->signer(), $config->signingKey());
return new JsonResponse([
'token' => $token->toString(),
'topic' => $topic,
'userId' => $user->getId(),
]);
}
}

View File

@ -5,6 +5,7 @@ namespace App\Controller;
use App\Repository\UserRepository;
use App\Repository\UsersOrganizationsRepository;
use App\Service\AccessTokenService;
use App\Service\OrganizationsService;
use App\Service\UserService;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
@ -27,7 +28,9 @@ class SecurityController extends AbstractController
private readonly UserRepository $userRepository,
private readonly UserService $userService,
private readonly UsersOrganizationsRepository $uoRepository,
private readonly LoggerInterface $logger, private readonly EntityManagerInterface $entityManager)
private readonly LoggerInterface $logger,
private readonly EntityManagerInterface $entityManager,
private readonly OrganizationsService $organizationsService)
{
$this->cguUserService = $cguUserService;
}
@ -35,14 +38,9 @@ class SecurityController extends AbstractController
#[Route(path: '/login', name: 'app_login')]
public function login(AuthenticationUtils $authenticationUtils): Response
{
// get the login error if there is one
$error = $authenticationUtils->getLastAuthenticationError();
// last username entered by the user
$lastUsername = $authenticationUtils->getLastUsername();
return $this->render('security/login.html.twig', [
'last_username' => $lastUsername,
'error' => $error,
@ -52,38 +50,30 @@ class SecurityController extends AbstractController
#[Route(path: '/sso_logout', name: 'sso_logout')]
public function ssoLogout(RequestStack $stack, LoggerInterface $logger, AccessTokenService $accessTokenService, Security $security): Response
{
// Invalidate the session and revoke tokens
try{
if( $stack->getSession()->invalidate()){
$accessTokenService->revokeTokens($security->getUser()->getUserIdentifier());
$security->logout(false);
$logger->info("Logout successfully");
// Redirect back to the client (or to a “you are logged out” page)
return $this->redirect('/');
}
}catch (\Exception $e){
$logger->log(LogLevel::ERROR, 'Error invalidating session: ' . $e->getMessage());
}
// If something goes wrong, redirect to the index page
return $this->redirectToRoute('app_index');
}
#[Route(path: '/consent', name: 'app_consent')]
public function consent(Request $request): Response
{
// Handle form submission
if ($request->isMethod('POST')) {
// Check if user declined consent
if (!$request->request->has('decline')) {
// User accepted the CGU, save this in the database
$this->cguUserService->acceptLatestCgu($this->getUser());
}
// Redirect back to the OAuth authorization endpoint with all the query parameters
return $this->redirectToRoute('oauth2_authorize', $request->query->all());
}
// For GET requests, just show the consent form
return $this->render('security/consent.html.twig');
}
@ -137,6 +127,9 @@ class SecurityController extends AbstractController
$uo->setIsActive(true);
$this->entityManager->persist($uo);
$this->entityManager->flush();
$data = ['user' => $user, 'organization' => $uo->getOrganization()];
$this->organizationsService->notifyOrganizationAdmins($data, "USER_ACCEPTED");
}
return $this->redirectToRoute('app_index');
}

View File

@ -14,6 +14,7 @@ use App\Repository\UsersOrganizationsRepository;
use App\Service\ActionService;
use App\Service\AwsService;
use App\Service\EmailService;
use App\Service\OrganizationsService;
use App\Service\UserOrganizationAppService;
use App\Service\UserOrganizationService;
use App\Service\UserService;
@ -43,7 +44,7 @@ class UserController extends AbstractController
private readonly UserOrganizationService $userOrganizationService,
private readonly UserRepository $userRepository,
private readonly UsersOrganizationsRepository $uoRepository,
private readonly OrganizationsRepository $organizationRepository, private readonly LoggerInterface $logger, private readonly EmailService $emailService, private readonly AwsService $awsService,
private readonly OrganizationsRepository $organizationRepository, private readonly LoggerInterface $logger, private readonly EmailService $emailService, private readonly AwsService $awsService, private readonly OrganizationsService $organizationsService,
)
{
}
@ -200,7 +201,7 @@ class UserController extends AbstractController
$this->entityManager->flush();
if( $orgId) {
return $this->redirectToRoute('user_show', ['id' => $user->getId(), 'organizationId' => $orgId]);
return $this->redirectToRoute('organization_show', ['organizationId' => $orgId]);
}
return $this->redirectToRoute('user_index');
}
@ -217,8 +218,6 @@ class UserController extends AbstractController
}
return $this->redirectToRoute('user_index');
}
throw $this->createAccessDeniedException(self::ACCESS_DENIED);
}
//TODO : MONOLOG
@ -295,6 +294,9 @@ class UserController extends AbstractController
}
$uo->setIsActive(false);
$this->userOrganizationAppService->deactivateAllUserOrganizationsAppLinks($uo);
$data = ['user' => $user,
'organization' => $org];
$this->organizationsService->notifyOrganizationAdmins($data,"USER_DEACTIVATED");
$this->entityManager->persist($uo);
$this->entityManager->flush();
$this->actionService->createAction("Deactivate user in organization", $actingUser, $org, $org->getName() . " for user " . $user->getUserIdentifier());
@ -331,6 +333,9 @@ class UserController extends AbstractController
$this->entityManager->persist($uo);
$this->entityManager->flush();
$this->actionService->createAction("Activate user in organization", $actingUser, $org, $org->getName() . " for user " . $user->getUserIdentifier());
$data = ['user' => $user,
'organization' => $org];
$this->organizationsService->notifyOrganizationAdmins($data,"USER_ACTIVATED");
return $this->redirectToRoute('user_index');
}
@ -357,6 +362,9 @@ class UserController extends AbstractController
$this->entityManager->persist($user);
$this->entityManager->flush();
$this->actionService->createAction("Delete user", $actingUser, null, $user->getUserIdentifier());
$data = ['user' => $user,
'organization' => null];
$this->organizationsService->notifyOrganizationAdmins($data,"USER_DELETED");
return new Response('', Response::HTTP_NO_CONTENT); //204
}
@ -633,8 +641,10 @@ class UserController extends AbstractController
}
$uo->setModifiedAt(new \DateTimeImmutable());
try {
$data = ['user'=>$uo->getUsers(), 'organization'=>$uo->getOrganization()];
$this->emailService->sendPasswordSetupEmail($user, $orgId);
$this->logger->info("Invitation email resent to user " . $user->getUserIdentifier() . " for organization " . $org->getName());
$this->organizationsService->notifyOrganizationAdmins($data,'USER_INVITED');
return $this->json(['message' => 'Invitation envoyée avec success.'], Response::HTTP_OK);
} catch (\Exception $e) {
$this->logger->error("Error resending invitation email to user " . $user->getUserIdentifier() . " for organization " . $org->getName() . ": " . $e->getMessage());

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

@ -0,0 +1,177 @@
<?php
namespace App\Entity;
use App\Repository\NotificationRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: NotificationRepository::class)]
#[ORM\Table(name: 'notifications')]
#[ORM\Index(columns: ['user_id', 'is_read', 'created_at'], name: 'idx_user_read_created')]
class Notification
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private ?User $user = null;
#[ORM\Column(length: 50)]
private ?string $type = null;
#[ORM\Column(length: 255)]
private ?string $title = null;
#[ORM\Column(type: Types::TEXT)]
private ?string $message = null;
#[ORM\Column(type: Types::JSON, nullable: true)]
private ?array $data = null;
#[ORM\Column(type: Types::BOOLEAN, options: ['default' => false])]
private bool $isRead = false;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
private ?\DateTimeImmutable $createdAt = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $readAt = null;
#[ORM\ManyToOne(targetEntity: Organizations::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'CASCADE')]
private ?Organizations $organization = null;
public function __construct()
{
$this->createdAt = new \DateTimeImmutable();
}
public function getId(): ?int
{
return $this->id;
}
public function getUser(): ?User
{
return $this->user;
}
public function setUser(?User $user): static
{
$this->user = $user;
return $this;
}
public function getType(): ?string
{
return $this->type;
}
public function setType(string $type): static
{
$this->type = $type;
return $this;
}
public function getTitle(): ?string
{
return $this->title;
}
public function setTitle(string $title): static
{
$this->title = $title;
return $this;
}
public function getMessage(): ?string
{
return $this->message;
}
public function setMessage(string $message): static
{
$this->message = $message;
return $this;
}
public function getData(): ?array
{
return $this->data;
}
public function setData(?array $data): static
{
$this->data = $data;
return $this;
}
public function isRead(): bool
{
return $this->isRead;
}
public function setIsRead(bool $isRead): static
{
$this->isRead = $isRead;
if ($isRead && !$this->readAt) {
$this->readAt = new \DateTimeImmutable();
}
return $this;
}
public function getCreatedAt(): ?\DateTimeImmutable
{
return $this->createdAt;
}
public function setCreatedAt(\DateTimeImmutable $createdAt): static
{
$this->createdAt = $createdAt;
return $this;
}
public function getReadAt(): ?\DateTimeImmutable
{
return $this->readAt;
}
public function setReadAt(?\DateTimeImmutable $readAt): static
{
$this->readAt = $readAt;
return $this;
}
public function getOrganization(): ?Organizations
{
return $this->organization;
}
public function setOrganization(?Organizations $organization): static
{
$this->organization = $organization;
return $this;
}
public function toArray(): array
{
return [
'id' => $this->id,
'type' => $this->type,
'title' => $this->title,
'message' => $this->message,
'data' => $this->data,
'isRead' => $this->isRead,
'createdAt' => $this->createdAt?->format('c'),
'readAt' => $this->readAt?->format('c'),
'organization' => $this->organization ? [
'id' => $this->organization->getId(),
'name' => $this->organization->getName(),
] : null,
];
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\Message;
class NotificationMessage
{
public function __construct(
private readonly int $userId,
private readonly string $type,
private readonly string $title,
private readonly string $message,
private readonly ?array $data = null,
private readonly ?int $organizationId = null
) {
}
public function getUserId(): int
{
return $this->userId;
}
public function getType(): string
{
return $this->type;
}
public function getTitle(): string
{
return $this->title;
}
public function getMessage(): string
{
return $this->message;
}
public function getData(): ?array
{
return $this->data;
}
public function getOrganizationId(): ?int
{
return $this->organizationId;
}
}

View File

@ -0,0 +1,61 @@
<?php
namespace App\MessageHandler;
use App\Entity\Notification;
use App\Entity\Organizations;
use App\Entity\User;
use App\Message\NotificationMessage;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
class NotificationMessageHandler
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly HubInterface $hub
) {
}
public function __invoke(NotificationMessage $message): void
{
$user = $this->entityManager->getRepository(User::class)->find($message->getUserId());
if (!$user) {
return;
}
$notification = new Notification();
$notification->setUser($user);
$notification->setType($message->getType());
$notification->setTitle($message->getTitle());
$notification->setMessage($message->getMessage());
$notification->setData($message->getData());
if ($message->getOrganizationId()) {
$organization = $this->entityManager->getRepository(Organizations::class)->find($message->getOrganizationId());
$notification->setOrganization($organization);
}
$this->entityManager->persist($notification);
$this->entityManager->flush();
$this->publishToMercure($notification);
}
private function publishToMercure(Notification $notification): void
{
$topic = sprintf('http://portail.solutions-easy.moi/notifications/user/%d', $notification->getUser()->getId());
$update = new Update(
$topic,
json_encode($notification->toArray()),
true
);
$this->hub->publish($update);
}
}

View File

@ -0,0 +1,77 @@
<?php
namespace App\Repository;
use App\Entity\Notification;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class NotificationRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Notification::class);
}
public function findUnreadByUser(User $user, int $limit = 50): array
{
return $this->createQueryBuilder('n')
->where('n.user = :user')
->andWhere('n.isRead = false')
->setParameter('user', $user)
->orderBy('n.createdAt', 'DESC')
->setMaxResults($limit)
->getQuery()
->getResult();
}
public function findRecentByUser(User $user, int $limit = 50): array
{
return $this->createQueryBuilder('n')
->where('n.user = :user')
->setParameter('user', $user)
->orderBy('n.createdAt', 'DESC')
->setMaxResults($limit)
->getQuery()
->getResult();
}
public function countUnreadByUser(User $user): int
{
return $this->createQueryBuilder('n')
->select('COUNT(n.id)')
->where('n.user = :user')
->andWhere('n.isRead = false')
->setParameter('user', $user)
->getQuery()
->getSingleScalarResult();
}
public function markAllAsReadForUser(User $user): int
{
return $this->createQueryBuilder('n')
->update()
->set('n.isRead', 'true')
->set('n.readAt', ':now')
->where('n.user = :user')
->andWhere('n.isRead = false')
->setParameter('user', $user)
->setParameter('now', new \DateTimeImmutable())
->getQuery()
->execute();
}
public function deleteOldReadNotifications(int $daysOld = 30): int
{
$date = new \DateTimeImmutable("-{$daysOld} days");
return $this->createQueryBuilder('n')
->delete()
->where('n.isRead = true')
->andWhere('n.readAt < :date')
->setParameter('date', $date)
->getQuery()
->execute();
}
}

View File

@ -0,0 +1,191 @@
<?php
namespace App\Service;
use App\Entity\Organizations;
use App\Entity\User;
use App\Message\NotificationMessage;
use Symfony\Component\Messenger\MessageBusInterface;
class NotificationService
{
public const TYPE_USER_JOINED = 'user_joined';
public const TYPE_USER_INVITED = 'user_invited';
public const TYPE_USER_ACCEPTED = 'user_accepted';
public const TYPE_USER_REMOVED = 'user_removed';
public const TYPE_ORG_UPDATE = 'org_update';
public const TYPE_APP_ACCESS = 'app_access';
public const TYPE_ROLE_CHANGED = 'role_changed';
public function __construct(
private readonly MessageBusInterface $messageBus
) {
}
public function notifyUserInvited(User $recipient, User $invitedUser, Organizations $organization): void
{
$this->send(
recipient: $recipient,
type: self::TYPE_USER_INVITED,
title: 'Invitation envoyée',
message: sprintf('%s %s a été invité à rejoindre %s', $invitedUser->getName(), $invitedUser->getSurname(), $organization->getName()),
data: [
'userId' => $invitedUser->getId(),
'userName' => $invitedUser->getName() . ' ' . $invitedUser->getSurname(),
'userEmail' => $invitedUser->getEmail(),
'organizationId' => $organization->getId(),
'organizationName' => $organization->getName(),
],
organization: $organization
);
}
public function notifyUserAcceptedInvite(User $recipient, User $acceptedUser, Organizations $organization): void
{
$this->send(
recipient: $recipient,
type: self::TYPE_USER_ACCEPTED,
title: 'Invitation acceptée',
message: sprintf('%s %s a accepté l\'invitation à %s', $acceptedUser->getName(), $acceptedUser->getSurname(), $organization->getName()),
data: [
'userId' => $acceptedUser->getId(),
'userName' => $acceptedUser->getName() . ' ' . $acceptedUser->getSurname(),
'userEmail' => $acceptedUser->getEmail(),
'organizationId' => $organization->getId(),
'organizationName' => $organization->getName(),
],
organization: $organization
);
}
public function notifyUserDeactivated(User $recipient, User $removedUser, Organizations $organization): void
{
$this->send(
recipient: $recipient,
type: self::TYPE_USER_REMOVED,
title: 'Membre retiré',
message: sprintf('%s %s a été désactivé de %s', $removedUser->getName(), $removedUser->getSurname(), $organization->getName()),
data: [
'userId' => $removedUser->getId(),
'userName' => $removedUser->getName() . ' ' . $removedUser->getSurname(),
'organizationId' => $organization->getId(),
'organizationName' => $organization->getName(),
],
organization: $organization
);
}
public function notifyUserActivated(User $recipient, User $activatedUser, Organizations $organization): void
{
$this->send(
recipient: $recipient,
type: 'user_activated',
title: 'Membre réactivé',
message: sprintf('%s %s a été réactivé dans %s', $activatedUser->getName(), $activatedUser->getSurname(), $organization->getName()),
data: [
'userId' => $activatedUser->getId(),
'userName' => $activatedUser->getName() . ' ' . $activatedUser->getSurname(),
'organizationId' => $organization->getId(),
'organizationName' => $organization->getName(),
],
organization: $organization
);
}
public function notifyOrganizationUpdate(User $recipient, Organizations $organization, string $action): void
{
$this->send(
recipient: $recipient,
type: self::TYPE_ORG_UPDATE,
title: 'Organisation mise à jour',
message: sprintf('L\'organisation %s a été %s', $organization->getName(), $action),
data: [
'organizationId' => $organization->getId(),
'organizationName' => $organization->getName(),
'action' => $action,
],
organization: $organization
);
}
public function notifyAppAccessChanged(User $recipient, Organizations $organization, string $appName, bool $granted): void
{
$action = $granted ? 'autorisé' : 'retiré';
$this->send(
recipient: $recipient,
type: self::TYPE_APP_ACCESS,
title: 'Accès application modifié',
message: sprintf('L\'accès à %s a été %s pour %s', $appName, $action, $organization->getName()),
data: [
'organizationId' => $organization->getId(),
'organizationName' => $organization->getName(),
'appName' => $appName,
'granted' => $granted,
],
organization: $organization
);
}
public function notifyRoleChanged(User $recipient, User $targetUser, Organizations $organization, string $newRole): void
{
$this->send(
recipient: $recipient,
type: self::TYPE_ROLE_CHANGED,
title: 'Rôle modifié',
message: sprintf('Le rôle de %s %s a été changé en %s dans %s',
$targetUser->getName(),
$targetUser->getSurname(),
$newRole,
$organization->getName()
),
data: [
'userId' => $targetUser->getId(),
'userName' => $targetUser->getName() . ' ' . $targetUser->getSurname(),
'organizationId' => $organization->getId(),
'organizationName' => $organization->getName(),
'newRole' => $newRole,
],
organization: $organization
);
}
public function notifyUserDeleted(User $recipient, User $deletedUser, ?Organizations $organization = null): void
{
$this->send(
recipient: $recipient,
type: 'user_deleted',
title: 'Utilisateur supprimé',
message: sprintf('L\'utilisateur %s %s a été supprimé du système',
$deletedUser->getName(),
$deletedUser->getSurname()
),
data: [
'userId' => $deletedUser->getId(),
'userName' => $deletedUser->getName() . ' ' . $deletedUser->getSurname(),
'userEmail' => $deletedUser->getEmail(),
],
organization: $organization
);
}
private function send(
User $recipient,
string $type,
string $title,
string $message,
?array $data = null,
?Organizations $organization = null
): void {
$notificationMessage = new NotificationMessage(
userId: $recipient->getId(),
type: $type,
title: $title,
message: $message,
data: $data,
organizationId: $organization?->getId()
);
$this->messageBus->dispatch($notificationMessage);
}
}

View File

@ -4,7 +4,10 @@ namespace App\Service;
use App\Entity\Apps;
use App\Entity\Organizations;
use App\Service\AwsService;
use App\Entity\Roles;
use App\Entity\UserOrganizatonApp;
use App\Repository\UsersOrganizationsRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\File\Exception\FileException;
class OrganizationsService
@ -12,7 +15,10 @@ class OrganizationsService
private string $logoDirectory;
public function __construct(
string $logoDirectory, private readonly AwsService $awsService
string $logoDirectory, private readonly AwsService $awsService,
private readonly EntityManagerInterface $entityManager,
private readonly UsersOrganizationsRepository $uoRepository,
private readonly NotificationService $notificationService
)
{
$this->logoDirectory = $logoDirectory;
@ -55,4 +61,75 @@ class OrganizationsService
return $result;
}
public function notifyOrganizationAdmins(array $data, string $type): void
{
$roleAdmin = $this->entityManager->getRepository(Roles::class)->findOneBy(['name' => 'ADMIN']);
$adminUOs = $this->uoRepository->findBy(['organization' => $data['organization'], 'isActive' => true]);
foreach ($adminUOs as $adminUO) {
$uoa = $this->entityManager->getRepository(UserOrganizatonApp::class)
->findOneBy([
'userOrganization' => $adminUO,
'role' => $roleAdmin,
'isActive' => true
]);
switch ($type) {
case 'USER_ACCEPTED':
if ($uoa && $adminUO->getUsers()->getId() !== $data['user']->getId() ) {
$newUser = $data['user'];
$this->notificationService->notifyUserAcceptedInvite(
$adminUO->getUsers(),
$newUser,
$data['organization']
);
}
break;
case 'USER_INVITED':
if ($uoa) {
$invitedUser = $data['user'];
$this->notificationService->notifyUserInvited(
$adminUO->getUsers(),
$invitedUser,
$data['organization']
);
}
break;
case 'USER_DEACTIVATED':
if ($uoa && $adminUO->getUsers()->getId() !== $data['user']->getId() ) {
$removedUser = $data['user'];
$this->notificationService->notifyUserDeactivated(
$adminUO->getUsers(),
$removedUser,
$data['organization']
);
}
break;
case 'USER_DELETED':
if ($uoa && $adminUO->getUsers()->getId() !== $data['user']->getId() ) {
$removedUser = $data['user'];
$this->notificationService->notifyUserDeleted(
$adminUO->getUsers(),
$removedUser,
$data['organization']
);
}
break;
case 'USER_ACTIVATED':
if ($uoa && $adminUO->getUsers()->getId() !== $data['user']->getId() ) {
$activatedUser = $data['user'];
$this->notificationService->notifyUserActivated(
$adminUO->getUsers(),
$activatedUser,
$data['organization']
);
}
break;
}
}
}
}

View File

@ -40,24 +40,36 @@
<li class="nav-item d-flex">
<img id="logo_orga" class="m-auto" src="{{asset('logo_org/logo-sudalys.png')}}" alt="logo organisation">
</li>
<li class="nav-item dropdown nav-notif">
<a id="notificationDropdown" class="nav-link count-indicator dropdown-toggle m-auto" href="#" data-bs-toggle="dropdown" aria-expanded="false">
<li class="nav-item dropdown nav-notif"
data-controller="notification"
data-notification-user-id-value="{{ app.user.id }}"
data-notification-mercure-url-value="{{ app.request.server.get('MERCURE_PUBLIC_URL') ?: 'http://mercure.solutions-easy.moi/.well-known/mercure' }}">
<a id="notificationDropdown"
class="nav-link count-indicator dropdown-toggle m-auto"
href="#"
data-bs-toggle="dropdown"
aria-expanded="false"
data-action="click->notification#markDropdownAsRead">
<i class="mx-0">{{ ux_icon('bi:bell', {height: '20px', width: '20px'}) }}</i>
<span class="count"></span>
<span class="count-notification" data-notification-target="badge" style="display: none;"></span>
</a>
<div class="dropdown-menu dropdown-menu-right navbar-dropdown preview-list" aria-labelledby="notificationDropdown">
<p class="mb-0 font-weight-normal float-left dropdown-header">Notifications</p>
<a class="dropdown-item preview-item" href="#" data-bs-toggle="dropdown">
<div class="preview-thumbnail">
<div class="preview-icon bg-primary">
<i class="mx-0 mb-1">{{ ux_icon('bi:info-circle', {height: '20px', width: '20px'}) }}</i>
</div>
<div class="dropdown-menu dropdown-menu-right navbar-dropdown preview-list"
aria-labelledby="notificationDropdown"
style="max-height: 400px; overflow-y: auto; min-width: 350px;">
<div class="d-flex justify-content-between align-items-center px-3 py-2 border-bottom">
<p class="mb-0 font-weight-normal dropdown-header">Notifications</p>
<button class="btn btn-sm btn-link text-primary p-0"
data-action="click->notification#markAllAsRead"
style="font-size: 0.875rem;">
Tout marquer comme lu
</button>
</div>
<div data-notification-target="list">
<div class="text-center py-4 text-muted">
<i class="mx-0 mb-2">{{ ux_icon('bi:bell-slash', {height: '30px', width: '30px'}) }}</i>
<p class="mb-0">Aucune notification</p>
</div>
<div class="preview-item-content">
<h6 class="preview-subject font-weight-normal">Information</h6>
<p class="font-weight-light small-text mb-0 text-muted">A l'instant</p>
</div>
</a>
</div>
</div>
</li>
<li class="nav-item dropdown nav-profile">