diff --git a/assets/app.js b/assets/app.js index 0ca5038..901e245 100644 --- a/assets/app.js +++ b/assets/app.js @@ -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'; diff --git a/assets/controllers/notification_controller.js b/assets/controllers/notification_controller.js new file mode 100644 index 0000000..35f6a26 --- /dev/null +++ b/assets/controllers/notification_controller.js @@ -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 = ` +
+ + + + + +

Aucune notification

+
+ `; + 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 ` + +
+
+ ${iconHtml} +
+
+
+
${this.escapeHtml(notification.title)}
+

${this.escapeHtml(notification.message)}

+

+ ${timeAgo} +

+
+ +
+ `; + } + + getIcon(type) { + const icons = { + user_joined: '', + user_invited: '', + user_accepted: '', + user_removed: '', + org_update: '', + app_access: '', + role_changed: '', + }; + 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 = ` +
+ ${this.getIcon(notification.type)} +
+
+
${this.escapeHtml(notification.title)}
+
${this.escapeHtml(notification.message)}
+
+ + `; + + this.toastContainer.appendChild(toast); + + setTimeout(() => toast.classList.add('show'), 10); + + setTimeout(() => { + toast.classList.remove('show'); + setTimeout(() => toast.remove(), 300); + }, 5000); + } +} diff --git a/assets/styles/notifications.css b/assets/styles/notifications.css new file mode 100644 index 0000000..c8e2aa4 --- /dev/null +++ b/assets/styles/notifications.css @@ -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; +} + diff --git a/config/packages/mercure.yaml b/config/packages/mercure.yaml index f2a7395..0dd804a 100644 --- a/config/packages/mercure.yaml +++ b/config/packages/mercure.yaml @@ -6,3 +6,4 @@ mercure: jwt: secret: '%env(MERCURE_JWT_SECRET)%' publish: '*' + subscribe: '*' diff --git a/migrations/Version20251029104354.php b/migrations/Version20251029104354.php new file mode 100644 index 0000000..5dbb66a --- /dev/null +++ b/migrations/Version20251029104354.php @@ -0,0 +1,35 @@ +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'); + } +} diff --git a/migrations/Version20251029104801.php b/migrations/Version20251029104801.php new file mode 100644 index 0000000..62cdbb9 --- /dev/null +++ b/migrations/Version20251029104801.php @@ -0,0 +1,32 @@ +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'); + } +} diff --git a/migrations/Version20251104081124.php b/migrations/Version20251104081124.php new file mode 100644 index 0000000..a6214aa --- /dev/null +++ b/migrations/Version20251104081124.php @@ -0,0 +1,32 @@ +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'); + } +} diff --git a/migrations/Version20251105083809.php b/migrations/Version20251105083809.php new file mode 100644 index 0000000..b97f459 --- /dev/null +++ b/migrations/Version20251105083809.php @@ -0,0 +1,33 @@ +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'); + } +} diff --git a/migrations/Version20251117104819.php b/migrations/Version20251117104819.php new file mode 100644 index 0000000..679f81e --- /dev/null +++ b/migrations/Version20251117104819.php @@ -0,0 +1,41 @@ +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'); + } +} diff --git a/migrations/Version20251117125146.php b/migrations/Version20251117125146.php new file mode 100644 index 0000000..3316a82 --- /dev/null +++ b/migrations/Version20251117125146.php @@ -0,0 +1,31 @@ +addSql('CREATE SCHEMA public'); + } +} diff --git a/src/Controller/NotificationController.php b/src/Controller/NotificationController.php new file mode 100644 index 0000000..f0826d6 --- /dev/null +++ b/src/Controller/NotificationController.php @@ -0,0 +1,144 @@ +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(), + ]); + } +} diff --git a/src/Controller/SecurityController.php b/src/Controller/SecurityController.php index f38032a..f51f040 100644 --- a/src/Controller/SecurityController.php +++ b/src/Controller/SecurityController.php @@ -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'); } diff --git a/src/Controller/UserController.php b/src/Controller/UserController.php index 0fb17e5..9afdc09 100644 --- a/src/Controller/UserController.php +++ b/src/Controller/UserController.php @@ -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()); diff --git a/src/Entity/Notification.php b/src/Entity/Notification.php new file mode 100644 index 0000000..ba77228 --- /dev/null +++ b/src/Entity/Notification.php @@ -0,0 +1,177 @@ + 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, + ]; + } +} diff --git a/src/Message/NotificationMessage.php b/src/Message/NotificationMessage.php new file mode 100644 index 0000000..c97ff15 --- /dev/null +++ b/src/Message/NotificationMessage.php @@ -0,0 +1,46 @@ +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; + } +} diff --git a/src/MessageHandler/NotificationMessageHandler.php b/src/MessageHandler/NotificationMessageHandler.php new file mode 100644 index 0000000..3647ed6 --- /dev/null +++ b/src/MessageHandler/NotificationMessageHandler.php @@ -0,0 +1,61 @@ +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); + } +} diff --git a/src/Repository/NotificationRepository.php b/src/Repository/NotificationRepository.php new file mode 100644 index 0000000..8cfbb38 --- /dev/null +++ b/src/Repository/NotificationRepository.php @@ -0,0 +1,77 @@ +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(); + } +} diff --git a/src/Service/NotificationService.php b/src/Service/NotificationService.php new file mode 100644 index 0000000..9f44b2f --- /dev/null +++ b/src/Service/NotificationService.php @@ -0,0 +1,191 @@ +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); + } +} diff --git a/src/Service/OrganizationsService.php b/src/Service/OrganizationsService.php index a0b1d90..ece09b3 100644 --- a/src/Service/OrganizationsService.php +++ b/src/Service/OrganizationsService.php @@ -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; + } + + } + } + } diff --git a/templates/elements/navbar.html.twig b/templates/elements/navbar.html.twig index 30f2894..9b52742 100644 --- a/templates/elements/navbar.html.twig +++ b/templates/elements/navbar.html.twig @@ -40,24 +40,36 @@ -