From fb6e30a0629031b058a830952ee26b15c9288ff8 Mon Sep 17 00:00:00 2001 From: fs Date: Thu, 19 Mar 2026 22:09:44 +0100 Subject: [PATCH] feat: add notifications module with bell UI, event listeners, and cleanup job In-app notification system with topbar bell icon, dropdown panel, mark-as-read/dismiss actions, and scheduled cleanup of old read notifications. Includes event listeners for user.created and user.deleted events, authorization policy, and full test coverage. Co-Authored-By: Claude Opus 4.6 (1M context) --- config/modules.php | 2 +- db/init/init.sql | 21 ++ lib/Service/User/UserAccountService.php | 8 + .../updates/2026-03-19-user-notifications.sql | 20 ++ .../Handler/NotificationCleanupJobHandler.php | 43 +++ .../UserCreatedNotificationListener.php | 70 +++++ .../UserDeletedNotificationListener.php | 65 +++++ .../NotificationsAuthorizationPolicy.php | 27 ++ .../NotificationsContainerRegistrar.php | 43 +++ .../Providers/NotificationsLayoutProvider.php | 18 ++ .../NotificationsSessionProvider.php | 35 +++ .../Repository/NotificationRepository.php | 135 +++++++++ .../NotificationRepositoryInterface.php | 26 ++ .../Service/NotificationRepositoryFactory.php | 16 ++ .../Service/NotificationService.php | 203 ++++++++++++++ .../Service/NotificationServicesFactory.php | 20 ++ .../pages/notifications/delete-data().php | 44 +++ .../pages/notifications/list-data().php | 34 +++ .../pages/notifications/mark-read-data().php | 48 ++++ .../notifications/unread-count-data().php | 29 ++ .../templates/topbar-notification-bell.phtml | 40 +++ .../UserCreatedNotificationListenerTest.php | 145 ++++++++++ .../Service/NotificationServiceTest.php | 129 +++++++++ .../components/app-notification-dropdown.css | 125 +++++++++ .../css/layout/app-topbar-notifications.css | 22 ++ .../js/components/app-notification-bell.js | 264 ++++++++++++++++++ web/modules/notifications | 1 + 27 files changed, 1632 insertions(+), 1 deletion(-) create mode 100644 modules/notifications/db/updates/2026-03-19-user-notifications.sql create mode 100644 modules/notifications/lib/Module/Notifications/Handler/NotificationCleanupJobHandler.php create mode 100644 modules/notifications/lib/Module/Notifications/Listeners/UserCreatedNotificationListener.php create mode 100644 modules/notifications/lib/Module/Notifications/Listeners/UserDeletedNotificationListener.php create mode 100644 modules/notifications/lib/Module/Notifications/NotificationsAuthorizationPolicy.php create mode 100644 modules/notifications/lib/Module/Notifications/NotificationsContainerRegistrar.php create mode 100644 modules/notifications/lib/Module/Notifications/Providers/NotificationsLayoutProvider.php create mode 100644 modules/notifications/lib/Module/Notifications/Providers/NotificationsSessionProvider.php create mode 100644 modules/notifications/lib/Module/Notifications/Repository/NotificationRepository.php create mode 100644 modules/notifications/lib/Module/Notifications/Repository/NotificationRepositoryInterface.php create mode 100644 modules/notifications/lib/Module/Notifications/Service/NotificationRepositoryFactory.php create mode 100644 modules/notifications/lib/Module/Notifications/Service/NotificationService.php create mode 100644 modules/notifications/lib/Module/Notifications/Service/NotificationServicesFactory.php create mode 100644 modules/notifications/pages/notifications/delete-data().php create mode 100644 modules/notifications/pages/notifications/list-data().php create mode 100644 modules/notifications/pages/notifications/mark-read-data().php create mode 100644 modules/notifications/pages/notifications/unread-count-data().php create mode 100644 modules/notifications/templates/topbar-notification-bell.phtml create mode 100644 modules/notifications/tests/Module/Notifications/Listeners/UserCreatedNotificationListenerTest.php create mode 100644 modules/notifications/tests/Module/Notifications/Service/NotificationServiceTest.php create mode 100644 modules/notifications/web/css/components/app-notification-dropdown.css create mode 100644 modules/notifications/web/css/layout/app-topbar-notifications.css create mode 100644 modules/notifications/web/js/components/app-notification-bell.js create mode 120000 web/modules/notifications diff --git a/config/modules.php b/config/modules.php index 49816d7..8c7c003 100644 --- a/config/modules.php +++ b/config/modules.php @@ -12,5 +12,5 @@ * Each entry must match a directory name under modules/ containing a module.php manifest. */ return [ - 'enabled_modules' => ['addressbook', 'bookmarks'], + 'enabled_modules' => ['addressbook', 'bookmarks', 'notifications'], ]; diff --git a/db/init/init.sql b/db/init/init.sql index 9aaf904..2571a8a 100644 --- a/db/init/init.sql +++ b/db/init/init.sql @@ -329,6 +329,27 @@ CREATE TABLE IF NOT EXISTS `user_bookmarks` ( CONSTRAINT `fk_ub_group` FOREIGN KEY (`group_id`) REFERENCES `user_bookmark_groups` (`id`) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE TABLE IF NOT EXISTS `user_notifications` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `recipient_user_id` INT UNSIGNED NOT NULL, + `tenant_id` INT UNSIGNED NULL, + `type` VARCHAR(60) NOT NULL, + `title` VARCHAR(255) NOT NULL, + `body` VARCHAR(500) NULL, + `link` VARCHAR(500) NULL, + `data` JSON NULL, + `is_read` TINYINT(1) NOT NULL DEFAULT 0, + `read_at` DATETIME NULL DEFAULT NULL, + `created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_un_recipient_read_created` (`recipient_user_id`, `is_read`, `created` DESC), + KEY `idx_un_recipient_created` (`recipient_user_id`, `created` DESC), + KEY `idx_un_tenant_created` (`tenant_id`, `created` DESC), + KEY `idx_un_cleanup` (`is_read`, `read_at`), + CONSTRAINT `fk_un_recipient` FOREIGN KEY (`recipient_user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_un_tenant` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + CREATE TABLE IF NOT EXISTS `tenant_custom_field_definitions` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `uuid` CHAR(36) NOT NULL, diff --git a/lib/Service/User/UserAccountService.php b/lib/Service/User/UserAccountService.php index 77eef01..60dfa81 100644 --- a/lib/Service/User/UserAccountService.php +++ b/lib/Service/User/UserAccountService.php @@ -102,6 +102,12 @@ class UserAccountService ]; } + // Capture tenant associations before CASCADE deletes them + $preDeletionTenantIds = array_column( + $this->userAssignmentService->buildAssignmentsForUser($userId)['tenants'] ?? [], + 'id' + ); + $deleted = $this->userWriteRepository->delete($userId); if (!$deleted) { return ['ok' => false, 'status' => 500, 'error' => 'delete_failed']; @@ -118,6 +124,8 @@ class UserAccountService 'user_id' => $userId, 'uuid' => (string) ($user['uuid'] ?? ''), 'actor_user_id' => $currentUserId, + 'display_name' => trim((string) ($user['display_name'] ?? '')), + 'tenant_ids' => $preDeletionTenantIds, ]); return ['ok' => true, 'user' => $user]; diff --git a/modules/notifications/db/updates/2026-03-19-user-notifications.sql b/modules/notifications/db/updates/2026-03-19-user-notifications.sql new file mode 100644 index 0000000..4b2bc03 --- /dev/null +++ b/modules/notifications/db/updates/2026-03-19-user-notifications.sql @@ -0,0 +1,20 @@ +CREATE TABLE IF NOT EXISTS `user_notifications` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `recipient_user_id` INT UNSIGNED NOT NULL, + `tenant_id` INT UNSIGNED NULL, + `type` VARCHAR(60) NOT NULL, + `title` VARCHAR(255) NOT NULL, + `body` VARCHAR(500) NULL, + `link` VARCHAR(500) NULL, + `data` JSON NULL, + `is_read` TINYINT(1) NOT NULL DEFAULT 0, + `read_at` DATETIME NULL DEFAULT NULL, + `created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_un_recipient_read_created` (`recipient_user_id`, `is_read`, `created` DESC), + KEY `idx_un_recipient_created` (`recipient_user_id`, `created` DESC), + KEY `idx_un_tenant_created` (`tenant_id`, `created` DESC), + KEY `idx_un_cleanup` (`is_read`, `read_at`), + CONSTRAINT `fk_un_recipient` FOREIGN KEY (`recipient_user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_un_tenant` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/modules/notifications/lib/Module/Notifications/Handler/NotificationCleanupJobHandler.php b/modules/notifications/lib/Module/Notifications/Handler/NotificationCleanupJobHandler.php new file mode 100644 index 0000000..500533b --- /dev/null +++ b/modules/notifications/lib/Module/Notifications/Handler/NotificationCleanupJobHandler.php @@ -0,0 +1,43 @@ + 'Notification cleanup', + 'description' => 'Purges read notifications older than 90 days', + 'default_enabled' => 1, + 'default_timezone' => defined('APP_TIMEZONE') ? (string) APP_TIMEZONE : 'UTC', + 'default_schedule_type' => 'daily', + 'default_schedule_interval' => 1, + 'default_schedule_time' => '04:00', + 'default_schedule_weekdays_csv' => null, + 'default_catchup_once' => 1, + 'allowed_schedule_types' => ['daily', 'weekly'], + ]; + } + + public function execute(?int $actorUserId): array + { + $deleted = $this->notificationService->purgeOldRead(90); + + return [ + 'status' => 'success', + 'error_code' => null, + 'error_message' => null, + 'result' => [ + 'deleted_count' => $deleted, + ], + ]; + } +} diff --git a/modules/notifications/lib/Module/Notifications/Listeners/UserCreatedNotificationListener.php b/modules/notifications/lib/Module/Notifications/Listeners/UserCreatedNotificationListener.php new file mode 100644 index 0000000..ccc0d8a --- /dev/null +++ b/modules/notifications/lib/Module/Notifications/Listeners/UserCreatedNotificationListener.php @@ -0,0 +1,70 @@ +userReadRepository->find($userId); + if ($user === null) { + return; + } + + $displayName = trim((string) ($user['display_name'] ?? '')); + if ($displayName === '') { + $displayName = trim(($user['first_name'] ?? '') . ' ' . ($user['last_name'] ?? '')); + } + + $tenantIds = $this->userTenantRepository->listTenantIdsByUserId($userId); + if ($tenantIds === []) { + return; + } + + // Only notify users who have the users.create permission (admins) + $adminUserIds = $this->userReadRepository->listPrivilegedUserIdsByPermissionKeys( + [PermissionService::USERS_CREATE] + ); + if ($adminUserIds === []) { + return; + } + $adminSet = array_flip($adminUserIds); + + $title = sprintf(t('New user: %s'), $displayName); + $link = 'admin/users/edit/' . $uuid; + + foreach ($tenantIds as $tenantId) { + $this->notificationService->createForTenantAdminUsers( + $tenantId, + 'user.created', + $title, + null, + $link, + ['user_id' => $userId, 'uuid' => $uuid], + $actorUserId, + $adminSet + ); + } + } +} diff --git a/modules/notifications/lib/Module/Notifications/Listeners/UserDeletedNotificationListener.php b/modules/notifications/lib/Module/Notifications/Listeners/UserDeletedNotificationListener.php new file mode 100644 index 0000000..343d029 --- /dev/null +++ b/modules/notifications/lib/Module/Notifications/Listeners/UserDeletedNotificationListener.php @@ -0,0 +1,65 @@ +notificationService->deleteAllForUser($userId); + + // Notify admins about the deletion + $displayName = trim((string) ($payload['display_name'] ?? '')); + $tenantIds = (array) ($payload['tenant_ids'] ?? []); + $actorUserId = isset($payload['actor_user_id']) ? (int) $payload['actor_user_id'] : null; + + if ($displayName === '' || $tenantIds === []) { + return; + } + + $adminUserIds = $this->userReadRepository->listPrivilegedUserIdsByPermissionKeys( + [PermissionService::USERS_DELETE] + ); + if ($adminUserIds === []) { + return; + } + $adminSet = array_flip($adminUserIds); + + $title = sprintf(t('User deleted: %s'), $displayName); + + foreach ($tenantIds as $tenantId) { + $tenantId = (int) $tenantId; + if ($tenantId <= 0) { + continue; + } + + $this->notificationService->createForTenantAdminUsers( + $tenantId, + 'user.deleted', + $title, + null, + null, + ['user_id' => $userId], + $actorUserId, + $adminSet + ); + } + } +} diff --git a/modules/notifications/lib/Module/Notifications/NotificationsAuthorizationPolicy.php b/modules/notifications/lib/Module/Notifications/NotificationsAuthorizationPolicy.php new file mode 100644 index 0000000..22a2bc2 --- /dev/null +++ b/modules/notifications/lib/Module/Notifications/NotificationsAuthorizationPolicy.php @@ -0,0 +1,27 @@ +set(NotificationRepositoryFactory::class, static fn (): NotificationRepositoryFactory => new NotificationRepositoryFactory()); + + $container->set(NotificationServicesFactory::class, static fn (AppContainer $c): NotificationServicesFactory => new NotificationServicesFactory( + $c->get(NotificationRepositoryFactory::class) + )); + + $container->set(NotificationService::class, static fn (AppContainer $c): NotificationService => $c->get(NotificationServicesFactory::class)->createNotificationService()); + + $container->set(NotificationCleanupJobHandler::class, static fn (AppContainer $c): NotificationCleanupJobHandler => new NotificationCleanupJobHandler( + $c->get(NotificationService::class) + )); + + $container->set(UserCreatedNotificationListener::class, static fn (AppContainer $c): UserCreatedNotificationListener => new UserCreatedNotificationListener( + $c->get(NotificationService::class), + $c->get(UserReadRepositoryInterface::class), + $c->get(UserTenantRepositoryInterface::class) + )); + + $container->set(UserDeletedNotificationListener::class, static fn (AppContainer $c): UserDeletedNotificationListener => new UserDeletedNotificationListener( + $c->get(NotificationService::class), + $c->get(UserReadRepositoryInterface::class) + )); + } +} diff --git a/modules/notifications/lib/Module/Notifications/Providers/NotificationsLayoutProvider.php b/modules/notifications/lib/Module/Notifications/Providers/NotificationsLayoutProvider.php new file mode 100644 index 0000000..2317e43 --- /dev/null +++ b/modules/notifications/lib/Module/Notifications/Providers/NotificationsLayoutProvider.php @@ -0,0 +1,18 @@ + [ + 'unread_count' => (int) ($session['module.notifications.unread_count'] ?? 0), + ], + ]; + } +} diff --git a/modules/notifications/lib/Module/Notifications/Providers/NotificationsSessionProvider.php b/modules/notifications/lib/Module/Notifications/Providers/NotificationsSessionProvider.php new file mode 100644 index 0000000..837b3af --- /dev/null +++ b/modules/notifications/lib/Module/Notifications/Providers/NotificationsSessionProvider.php @@ -0,0 +1,35 @@ +get(SessionStoreInterface::class); + $service = $container->get(NotificationService::class); + if (!$sessionStore instanceof SessionStoreInterface || !$service instanceof NotificationService) { + return; + } + + $sessionStore->set(self::SESSION_KEY, $service->unreadCount($userId)); + } + + public function clear(): void + { + unset($_SESSION[self::SESSION_KEY]); + } +} diff --git a/modules/notifications/lib/Module/Notifications/Repository/NotificationRepository.php b/modules/notifications/lib/Module/Notifications/Repository/NotificationRepository.php new file mode 100644 index 0000000..855fa39 --- /dev/null +++ b/modules/notifications/lib/Module/Notifications/Repository/NotificationRepository.php @@ -0,0 +1,135 @@ + 0 ? $result : false; + } + + public function listByUser(int $userId, int $limit, int $offset): array + { + if ($userId <= 0) { + return []; + } + + $limit = max(1, min($limit, 100)); + $offset = max(0, $offset); + + $rows = DB::select( + 'select id, type, title, body, link, is_read, created from user_notifications where recipient_user_id = ? order by created desc limit ? offset ?', + (string) $userId, + (string) $limit, + (string) $offset + ); + + return $this->unwrapList($rows); + } + + public function countUnreadByUser(int $userId): int + { + if ($userId <= 0) { + return 0; + } + + $count = DB::selectValue( + 'select count(*) from user_notifications where recipient_user_id = ? and is_read = 0', + (string) $userId + ); + + return (int) $count; + } + + public function markRead(int $id, int $userId): bool + { + if ($id <= 0 || $userId <= 0) { + return false; + } + + $affected = DB::update( + 'update user_notifications set is_read = 1, read_at = NOW() where id = ? and recipient_user_id = ? and is_read = 0', + (string) $id, + (string) $userId + ); + + return is_int($affected) && $affected > 0; + } + + public function markAllReadByUser(int $userId): int + { + if ($userId <= 0) { + return 0; + } + + $affected = DB::update( + 'update user_notifications set is_read = 1, read_at = NOW() where recipient_user_id = ? and is_read = 0', + (string) $userId + ); + + return is_int($affected) ? $affected : 0; + } + + public function delete(int $id, int $userId): bool + { + if ($id <= 0 || $userId <= 0) { + return false; + } + + $affected = DB::delete( + 'delete from user_notifications where id = ? and recipient_user_id = ?', + (string) $id, + (string) $userId + ); + + return is_int($affected) && $affected > 0; + } + + public function deleteAllByUser(int $userId): int + { + if ($userId <= 0) { + return 0; + } + + $affected = DB::delete( + 'delete from user_notifications where recipient_user_id = ?', + (string) $userId + ); + + return is_int($affected) ? $affected : 0; + } + + public function purgeReadOlderThanDays(int $days): int + { + if ($days <= 0) { + return 0; + } + + $affected = DB::delete( + 'delete from user_notifications where is_read = 1 and read_at < DATE_SUB(NOW(), INTERVAL ? DAY)', + (string) $days + ); + + return is_int($affected) ? $affected : 0; + } +} diff --git a/modules/notifications/lib/Module/Notifications/Repository/NotificationRepositoryInterface.php b/modules/notifications/lib/Module/Notifications/Repository/NotificationRepositoryInterface.php new file mode 100644 index 0000000..8dcc612 --- /dev/null +++ b/modules/notifications/lib/Module/Notifications/Repository/NotificationRepositoryInterface.php @@ -0,0 +1,26 @@ +> + */ + public function listByUser(int $userId, int $limit, int $offset): array; + + public function countUnreadByUser(int $userId): int; + + public function markRead(int $id, int $userId): bool; + + public function markAllReadByUser(int $userId): int; + + public function delete(int $id, int $userId): bool; + + public function deleteAllByUser(int $userId): int; + + public function purgeReadOlderThanDays(int $days): int; +} diff --git a/modules/notifications/lib/Module/Notifications/Service/NotificationRepositoryFactory.php b/modules/notifications/lib/Module/Notifications/Service/NotificationRepositoryFactory.php new file mode 100644 index 0000000..32948f7 --- /dev/null +++ b/modules/notifications/lib/Module/Notifications/Service/NotificationRepositoryFactory.php @@ -0,0 +1,16 @@ +notificationRepository ??= new NotificationRepository(); + } +} diff --git a/modules/notifications/lib/Module/Notifications/Service/NotificationService.php b/modules/notifications/lib/Module/Notifications/Service/NotificationService.php new file mode 100644 index 0000000..05c22e6 --- /dev/null +++ b/modules/notifications/lib/Module/Notifications/Service/NotificationService.php @@ -0,0 +1,203 @@ +> */ + public function listForUser(int $userId, int $limit = 50): array + { + if ($userId <= 0) { + return []; + } + + return $this->notificationRepository->listByUser($userId, $limit, 0); + } + + public function unreadCount(int $userId): int + { + if ($userId <= 0) { + return 0; + } + + return $this->notificationRepository->countUnreadByUser($userId); + } + + public function markAsRead(int $userId, int $notificationId): bool + { + if ($userId <= 0 || $notificationId <= 0) { + return false; + } + + return $this->notificationRepository->markRead($notificationId, $userId); + } + + public function markAllAsRead(int $userId): int + { + if ($userId <= 0) { + return 0; + } + + return $this->notificationRepository->markAllReadByUser($userId); + } + + public function dismiss(int $userId, int $notificationId): bool + { + if ($userId <= 0 || $notificationId <= 0) { + return false; + } + + return $this->notificationRepository->delete($notificationId, $userId); + } + + public function createForUser( + int $recipientUserId, + ?int $tenantId, + string $type, + string $title, + ?string $body, + ?string $link, + array $data + ): int|false { + if ($recipientUserId <= 0 || $type === '' || $title === '') { + return false; + } + + return $this->notificationRepository->create([ + 'recipient_user_id' => $recipientUserId, + 'tenant_id' => $tenantId, + 'type' => $type, + 'title' => $title, + 'body' => $body, + 'link' => $link, + 'data' => $data ?: null, + ]); + } + + /** + * Fan-out: create notification for all active users in a tenant, optionally excluding actor. + * + * @return int Number of notifications created + */ + public function createForTenantUsers( + int $tenantId, + string $type, + string $title, + ?string $body, + ?string $link, + array $data, + ?int $excludeUserId + ): int { + if ($tenantId <= 0 || $type === '' || $title === '') { + return 0; + } + + $userIds = $this->listUserIdsForTenant($tenantId); + $created = 0; + + foreach ($userIds as $userId) { + if ($excludeUserId !== null && $userId === $excludeUserId) { + continue; + } + + $result = $this->createForUser($userId, $tenantId, $type, $title, $body, $link, $data); + if ($result !== false) { + $created++; + } + } + + return $created; + } + + /** + * Fan-out: create notification only for tenant users whose ID is in $allowedUserIds. + * + * @param array $allowedUserIds Flipped array (user ID as key) for fast lookup + * @return int Number of notifications created + */ + public function createForTenantAdminUsers( + int $tenantId, + string $type, + string $title, + ?string $body, + ?string $link, + array $data, + ?int $excludeUserId, + array $allowedUserIds + ): int { + if ($tenantId <= 0 || $type === '' || $title === '' || $allowedUserIds === []) { + return 0; + } + + $userIds = $this->listUserIdsForTenant($tenantId); + $created = 0; + + foreach ($userIds as $userId) { + if (!isset($allowedUserIds[$userId])) { + continue; + } + if ($excludeUserId !== null && $userId === $excludeUserId) { + continue; + } + + $result = $this->createForUser($userId, $tenantId, $type, $title, $body, $link, $data); + if ($result !== false) { + $created++; + } + } + + return $created; + } + + public function purgeOldRead(int $retentionDays = 90): int + { + return $this->notificationRepository->purgeReadOlderThanDays($retentionDays); + } + + public function deleteAllForUser(int $userId): int + { + if ($userId <= 0) { + return 0; + } + + return $this->notificationRepository->deleteAllByUser($userId); + } + + /** + * Look up active user IDs belonging to a tenant. + * + * @return list + */ + private function listUserIdsForTenant(int $tenantId): array + { + if ($tenantId <= 0) { + return []; + } + + $rows = \MintyPHP\DB::select( + 'select ut.user_id from user_tenants ut join users u on u.id = ut.user_id and u.active = 1 where ut.tenant_id = ?', + (string) $tenantId + ); + + if (!is_array($rows)) { + return []; + } + + $ids = []; + foreach ($rows as $row) { + $data = $row['user_tenants'] ?? $row; + if (is_array($data) && isset($data['user_id'])) { + $ids[] = (int) $data['user_id']; + } + } + + return array_values(array_unique($ids)); + } +} diff --git a/modules/notifications/lib/Module/Notifications/Service/NotificationServicesFactory.php b/modules/notifications/lib/Module/Notifications/Service/NotificationServicesFactory.php new file mode 100644 index 0000000..e7335ef --- /dev/null +++ b/modules/notifications/lib/Module/Notifications/Service/NotificationServicesFactory.php @@ -0,0 +1,20 @@ +notificationService ??= new NotificationService( + $this->notificationRepositoryFactory->createNotificationRepository() + ); + } +} diff --git a/modules/notifications/pages/notifications/delete-data().php b/modules/notifications/pages/notifications/delete-data().php new file mode 100644 index 0000000..629a377 --- /dev/null +++ b/modules/notifications/pages/notifications/delete-data().php @@ -0,0 +1,44 @@ +method() !== 'POST') { + http_response_code(405); + Router::json(['ok' => false, 'error' => 'method_not_allowed']); + return; +} + +if (!Session::checkCsrfToken()) { + http_response_code(403); + Router::json(['ok' => false, 'error' => 'csrf']); + return; +} + +$session = app(SessionStoreInterface::class)->all(); +$userId = (int) ($session['user']['id'] ?? 0); +if ($userId <= 0) { + http_response_code(401); + Router::json(['ok' => false, 'error' => 'unauthorized']); + return; +} + +$id = (int) requestInput()->body('id'); +if ($id <= 0) { + http_response_code(400); + Router::json(['ok' => false, 'error' => 'invalid_request']); + return; +} + +$service = app(NotificationService::class); +$service->dismiss($userId, $id); + +$unreadCount = $service->unreadCount($userId); +app(SessionStoreInterface::class)->set('module.notifications.unread_count', $unreadCount); + +Router::json(['ok' => true, 'unread_count' => $unreadCount]); diff --git a/modules/notifications/pages/notifications/list-data().php b/modules/notifications/pages/notifications/list-data().php new file mode 100644 index 0000000..fccbd11 --- /dev/null +++ b/modules/notifications/pages/notifications/list-data().php @@ -0,0 +1,34 @@ +method() !== 'GET') { + http_response_code(405); + Router::json(['ok' => false, 'error' => 'method_not_allowed']); + return; +} + +$session = app(SessionStoreInterface::class)->all(); +$userId = (int) ($session['user']['id'] ?? 0); +if ($userId <= 0) { + http_response_code(401); + Router::json(['ok' => false, 'error' => 'unauthorized']); + return; +} + +$service = app(NotificationService::class); +$notifications = $service->listForUser($userId, 50); +$unreadCount = $service->unreadCount($userId); + +app(SessionStoreInterface::class)->set('module.notifications.unread_count', $unreadCount); + +Router::json([ + 'ok' => true, + 'notifications' => $notifications, + 'unread_count' => $unreadCount, +]); diff --git a/modules/notifications/pages/notifications/mark-read-data().php b/modules/notifications/pages/notifications/mark-read-data().php new file mode 100644 index 0000000..c71f440 --- /dev/null +++ b/modules/notifications/pages/notifications/mark-read-data().php @@ -0,0 +1,48 @@ +method() !== 'POST') { + http_response_code(405); + Router::json(['ok' => false, 'error' => 'method_not_allowed']); + return; +} + +if (!Session::checkCsrfToken()) { + http_response_code(403); + Router::json(['ok' => false, 'error' => 'csrf']); + return; +} + +$session = app(SessionStoreInterface::class)->all(); +$userId = (int) ($session['user']['id'] ?? 0); +if ($userId <= 0) { + http_response_code(401); + Router::json(['ok' => false, 'error' => 'unauthorized']); + return; +} + +$service = app(NotificationService::class); +$all = requestInput()->body('all'); +$id = (int) requestInput()->body('id'); + +if ($all) { + $service->markAllAsRead($userId); +} elseif ($id > 0) { + $service->markAsRead($userId, $id); +} else { + http_response_code(400); + Router::json(['ok' => false, 'error' => 'invalid_request']); + return; +} + +$unreadCount = $service->unreadCount($userId); +app(SessionStoreInterface::class)->set('module.notifications.unread_count', $unreadCount); + +Router::json(['ok' => true, 'unread_count' => $unreadCount]); diff --git a/modules/notifications/pages/notifications/unread-count-data().php b/modules/notifications/pages/notifications/unread-count-data().php new file mode 100644 index 0000000..7024058 --- /dev/null +++ b/modules/notifications/pages/notifications/unread-count-data().php @@ -0,0 +1,29 @@ +method() !== 'GET') { + http_response_code(405); + Router::json(['ok' => false, 'error' => 'method_not_allowed']); + return; +} + +$session = app(SessionStoreInterface::class)->all(); +$userId = (int) ($session['user']['id'] ?? 0); +if ($userId <= 0) { + http_response_code(401); + Router::json(['ok' => false, 'error' => 'unauthorized']); + return; +} + +$service = app(NotificationService::class); +$unreadCount = $service->unreadCount($userId); + +app(SessionStoreInterface::class)->set('module.notifications.unread_count', $unreadCount); + +Router::json(['ok' => true, 'unread_count' => $unreadCount]); diff --git a/modules/notifications/templates/topbar-notification-bell.phtml b/modules/notifications/templates/topbar-notification-bell.phtml new file mode 100644 index 0000000..d1b0146 --- /dev/null +++ b/modules/notifications/templates/topbar-notification-bell.phtml @@ -0,0 +1,40 @@ + +
  • + +
  • diff --git a/modules/notifications/tests/Module/Notifications/Listeners/UserCreatedNotificationListenerTest.php b/modules/notifications/tests/Module/Notifications/Listeners/UserCreatedNotificationListenerTest.php new file mode 100644 index 0000000..860f0c6 --- /dev/null +++ b/modules/notifications/tests/Module/Notifications/Listeners/UserCreatedNotificationListenerTest.php @@ -0,0 +1,145 @@ +notifService = $this->createMock(NotificationService::class); + $this->userRepo = $this->createMock(UserReadRepositoryInterface::class); + $this->utRepo = $this->createMock(UserTenantRepositoryInterface::class); + $this->listener = new UserCreatedNotificationListener($this->notifService, $this->userRepo, $this->utRepo); + } + + public function testHandleCreatesNotificationsForTenantAdminUsers(): void + { + $this->userRepo->method('find')->with(10)->willReturn([ + 'id' => 10, + 'display_name' => 'Alice Smith', + 'first_name' => 'Alice', + 'last_name' => 'Smith', + ]); + $this->utRepo->method('listTenantIdsByUserId')->with(10)->willReturn([1, 2]); + $this->userRepo->method('listPrivilegedUserIdsByPermissionKeys') + ->with([PermissionService::USERS_CREATE]) + ->willReturn([20, 30]); + + $this->notifService->expects($this->exactly(2)) + ->method('createForTenantAdminUsers') + ->willReturnCallback(function (int $tenantId, string $type, string $title, ?string $body, ?string $link, array $data, ?int $excludeUserId, array $adminSet): int { + $this->assertContains($tenantId, [1, 2]); + $this->assertSame('user.created', $type); + $this->assertStringContainsString('Alice Smith', $title); + $this->assertSame('admin/users/edit/abc-uuid', $link); + $this->assertSame(5, $excludeUserId); + $this->assertArrayHasKey(20, $adminSet); + $this->assertArrayHasKey(30, $adminSet); + return 2; + }); + + $this->listener->handle('user.created', [ + 'user_id' => 10, + 'uuid' => 'abc-uuid', + 'actor_user_id' => 5, + ]); + } + + public function testHandleExcludesActorFromRecipients(): void + { + $this->userRepo->method('find')->with(10)->willReturn([ + 'id' => 10, + 'display_name' => 'Bob', + ]); + $this->utRepo->method('listTenantIdsByUserId')->with(10)->willReturn([1]); + $this->userRepo->method('listPrivilegedUserIdsByPermissionKeys')->willReturn([20]); + + $this->notifService->expects($this->once()) + ->method('createForTenantAdminUsers') + ->with( + 1, + 'user.created', + $this->anything(), + null, + 'admin/users/edit/xyz', + $this->anything(), + 7, // actor excluded + $this->anything() + ) + ->willReturn(1); + + $this->listener->handle('user.created', [ + 'user_id' => 10, + 'uuid' => 'xyz', + 'actor_user_id' => 7, + ]); + } + + public function testHandleSkipsInvalidPayload(): void + { + $this->notifService->expects($this->never())->method('createForTenantAdminUsers'); + + $this->listener->handle('user.created', ['uuid' => 'abc']); + $this->listener->handle('user.created', ['user_id' => 0, 'uuid' => 'abc']); + $this->listener->handle('user.created', ['user_id' => 10, 'uuid' => '']); + } + + public function testHandleSkipsWhenUserNotFound(): void + { + $this->userRepo->method('find')->with(99)->willReturn(null); + $this->notifService->expects($this->never())->method('createForTenantAdminUsers'); + + $this->listener->handle('user.created', [ + 'user_id' => 99, + 'uuid' => 'abc', + 'actor_user_id' => 1, + ]); + } + + public function testHandleSkipsWhenNoTenants(): void + { + $this->userRepo->method('find')->with(10)->willReturn([ + 'id' => 10, + 'display_name' => 'Alice', + ]); + $this->utRepo->method('listTenantIdsByUserId')->with(10)->willReturn([]); + $this->notifService->expects($this->never())->method('createForTenantAdminUsers'); + + $this->listener->handle('user.created', [ + 'user_id' => 10, + 'uuid' => 'abc', + 'actor_user_id' => 1, + ]); + } + + public function testHandleSkipsWhenNoAdminUsers(): void + { + $this->userRepo->method('find')->with(10)->willReturn([ + 'id' => 10, + 'display_name' => 'Alice', + ]); + $this->utRepo->method('listTenantIdsByUserId')->with(10)->willReturn([1]); + $this->userRepo->method('listPrivilegedUserIdsByPermissionKeys')->willReturn([]); + + $this->notifService->expects($this->never())->method('createForTenantAdminUsers'); + + $this->listener->handle('user.created', [ + 'user_id' => 10, + 'uuid' => 'abc', + 'actor_user_id' => 1, + ]); + } +} diff --git a/modules/notifications/tests/Module/Notifications/Service/NotificationServiceTest.php b/modules/notifications/tests/Module/Notifications/Service/NotificationServiceTest.php new file mode 100644 index 0000000..49a54a7 --- /dev/null +++ b/modules/notifications/tests/Module/Notifications/Service/NotificationServiceTest.php @@ -0,0 +1,129 @@ +notifRepo = $this->createMock(NotificationRepositoryInterface::class); + $this->service = new NotificationService($this->notifRepo); + } + + public function testCreateForUserInsertsNotification(): void + { + $this->notifRepo->expects($this->once()) + ->method('create') + ->with($this->callback(function (array $data): bool { + return $data['recipient_user_id'] === 5 + && $data['tenant_id'] === 1 + && $data['type'] === 'user.created' + && $data['title'] === 'New user: Alice'; + })) + ->willReturn(42); + + $result = $this->service->createForUser(5, 1, 'user.created', 'New user: Alice', null, 'admin/users/edit/abc', []); + + $this->assertSame(42, $result); + } + + public function testCreateForUserRejectsMissingFields(): void + { + $this->notifRepo->expects($this->never())->method('create'); + + $this->assertFalse($this->service->createForUser(0, 1, 'user.created', 'Test', null, null, [])); + $this->assertFalse($this->service->createForUser(1, 1, '', 'Test', null, null, [])); + $this->assertFalse($this->service->createForUser(1, 1, 'user.created', '', null, null, [])); + } + + public function testUnreadCountReturnsCorrectCount(): void + { + $this->notifRepo->method('countUnreadByUser')->with(3)->willReturn(7); + + $this->assertSame(7, $this->service->unreadCount(3)); + } + + public function testUnreadCountReturnsZeroForInvalidUser(): void + { + $this->notifRepo->expects($this->never())->method('countUnreadByUser'); + + $this->assertSame(0, $this->service->unreadCount(0)); + } + + public function testMarkAsReadUpdatesState(): void + { + $this->notifRepo->expects($this->once()) + ->method('markRead') + ->with(10, 3) + ->willReturn(true); + + $this->assertTrue($this->service->markAsRead(3, 10)); + } + + public function testMarkAsReadRejectsInvalidIds(): void + { + $this->notifRepo->expects($this->never())->method('markRead'); + + $this->assertFalse($this->service->markAsRead(0, 10)); + $this->assertFalse($this->service->markAsRead(3, 0)); + } + + public function testMarkAllAsReadAffectsAllUserNotifications(): void + { + $this->notifRepo->expects($this->once()) + ->method('markAllReadByUser') + ->with(3) + ->willReturn(5); + + $this->assertSame(5, $this->service->markAllAsRead(3)); + } + + public function testDismissDeletesNotification(): void + { + $this->notifRepo->expects($this->once()) + ->method('delete') + ->with(10, 3) + ->willReturn(true); + + $this->assertTrue($this->service->dismiss(3, 10)); + } + + public function testPurgeOldReadRemovesExpiredOnly(): void + { + $this->notifRepo->expects($this->once()) + ->method('purgeReadOlderThanDays') + ->with(90) + ->willReturn(12); + + $this->assertSame(12, $this->service->purgeOldRead(90)); + } + + public function testDeleteAllForUserDelegates(): void + { + $this->notifRepo->expects($this->once()) + ->method('deleteAllByUser') + ->with(5) + ->willReturn(3); + + $this->assertSame(3, $this->service->deleteAllForUser(5)); + } + + public function testListForUserReturnsNotifications(): void + { + $expected = [ + ['id' => 1, 'title' => 'Test', 'is_read' => 0], + ['id' => 2, 'title' => 'Test 2', 'is_read' => 1], + ]; + $this->notifRepo->method('listByUser')->with(3, 50, 0)->willReturn($expected); + + $this->assertSame($expected, $this->service->listForUser(3)); + } +} diff --git a/modules/notifications/web/css/components/app-notification-dropdown.css b/modules/notifications/web/css/components/app-notification-dropdown.css new file mode 100644 index 0000000..5e4aa19 --- /dev/null +++ b/modules/notifications/web/css/components/app-notification-dropdown.css @@ -0,0 +1,125 @@ +@layer components { + ul.app-notification-dropdown { + width: 360px; + padding: 0; + } + + .app-notification-dropdown__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--app-border, #dee2e6); + } + + .app-notification-dropdown__title { + font-size: 14px; + font-weight: 600; + } + + .app-notification-dropdown__mark-all { + background: none; + border: none; + color: var(--app-primary, #105433); + font-size: 12px; + cursor: pointer; + padding: 2px 4px; + } + + .app-notification-dropdown__mark-all:hover { + text-decoration: underline; + } + + .app-notification-dropdown__body { + overflow-y: auto; + max-height: 400px; + padding: 0; + } + + .app-notification-item { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 10px 16px; + border-bottom: 1px solid var(--app-border-light, #f0f0f0); + cursor: pointer; + transition: background 0.15s; + } + + .app-notification-item:hover { + background: var(--app-hover, #f8f9fa); + } + + .app-notification-item--unread { + border-left: 3px solid var(--app-primary, #105433); + } + + .app-notification-item__icon { + flex-shrink: 0; + font-size: 16px; + margin-top: 2px; + color: var(--app-muted, #6c757d); + } + + .app-notification-item--unread .app-notification-item__icon { + color: var(--app-primary, #105433); + } + + .app-notification-item__content { + flex: 1; + min-width: 0; + } + + .app-notification-item__title { + font-size: 13px; + font-weight: 500; + line-height: 1.3; + margin: 0 0 2px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .app-notification-item__body { + font-size: 12px; + color: var(--app-muted, #6c757d); + line-height: 1.3; + margin: 0 0 2px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .app-notification-item__time { + font-size: 11px; + color: var(--app-muted, #6c757d); + } + + .app-notification-item__actions { + flex-shrink: 0; + display: flex; + gap: 4px; + opacity: 0; + transition: opacity 0.15s; + } + + .app-notification-item:hover .app-notification-item__actions { + opacity: 1; + } + + .app-notification-item__action-btn { + background: none; + border: none; + color: var(--app-muted, #6c757d); + font-size: 14px; + cursor: pointer; + padding: 2px; + line-height: 1; + border-radius: 4px; + } + + .app-notification-item__action-btn:hover { + color: var(--app-text, #212529); + background: var(--app-border-light, #f0f0f0); + } +} diff --git a/modules/notifications/web/css/layout/app-topbar-notifications.css b/modules/notifications/web/css/layout/app-topbar-notifications.css new file mode 100644 index 0000000..b06f2eb --- /dev/null +++ b/modules/notifications/web/css/layout/app-topbar-notifications.css @@ -0,0 +1,22 @@ +@layer layout { + .app-topbar-notification-menu > summary { + position: relative; + } + + .app-notification-badge { + position: absolute; + top: 2px; + right: 2px; + min-width: 18px; + height: 18px; + padding: 0 4px; + border-radius: 9px; + background: var(--app-danger, #dc3545); + color: #fff; + font-size: 11px; + font-weight: 600; + line-height: 18px; + text-align: center; + pointer-events: none; + } +} diff --git a/modules/notifications/web/js/components/app-notification-bell.js b/modules/notifications/web/js/components/app-notification-bell.js new file mode 100644 index 0000000..1b9373c --- /dev/null +++ b/modules/notifications/web/js/components/app-notification-bell.js @@ -0,0 +1,264 @@ +/** + * Notification bell runtime component. + * + * Works with the native
    dropdown. Handles polling, + * notification list rendering, mark-read and dismiss actions. + */ + +const POLL_INTERVAL_MS = 60_000; +const TYPE_ICONS = { + 'user.created': 'bi-person-plus', + 'user.deleted': 'bi-person-dash', + 'system': 'bi-gear', +}; + +function getCsrf() { + const root = document.documentElement; + return { + key: root.dataset.csrfKey || '', + token: root.dataset.csrfToken || '', + }; +} + +function timeAgo(dateStr) { + const now = Date.now(); + const then = new Date(dateStr).getTime(); + const diffSec = Math.floor((now - then) / 1000); + + if (diffSec < 60) return '< 1m'; + if (diffSec < 3600) return Math.floor(diffSec / 60) + 'm'; + if (diffSec < 86400) return Math.floor(diffSec / 3600) + 'h'; + return Math.floor(diffSec / 86400) + 'd'; +} + +function escapeHtml(str) { + const d = document.createElement('div'); + d.textContent = str; + return d.innerHTML; +} + +async function fetchJson(url, options = {}) { + const resp = await fetch(url, options); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + return resp.json(); +} + +export function initNotificationBell(root = document, config = {}) { + const details = root.querySelector('[data-notification-bell]'); + const dropdown = root.querySelector('[data-notification-dropdown]'); + if (!details || !dropdown) return { destroy() {} }; + + const badge = details.querySelector('[data-notification-badge]'); + const list = dropdown.querySelector('[data-notification-list]'); + const emptyEl = list?.querySelector('.app-empty-state'); + const markAllBtn = dropdown.querySelector('[data-notification-mark-all]'); + + const ac = new AbortController(); + const signal = ac.signal; + let pollTimer = null; + + // --- Badge --- + function updateBadge(count) { + if (!badge) return; + if (count > 0) { + badge.textContent = count > 99 ? '99+' : String(count); + badge.style.display = ''; + } else { + badge.textContent = ''; + badge.style.display = 'none'; + } + } + + // --- Render --- + function renderNotifications(notifications) { + if (!list) return; + + // Remove rendered items, keep empty state partial + list.querySelectorAll('.app-notification-item').forEach(el => el.remove()); + + if (notifications.length === 0) { + if (emptyEl) emptyEl.style.display = ''; + return; + } + if (emptyEl) emptyEl.style.display = 'none'; + + const frag = document.createDocumentFragment(); + for (const n of notifications) { + frag.appendChild(createNotificationItem(n)); + } + list.insertBefore(frag, emptyEl); + } + + function createNotificationItem(n) { + const item = document.createElement('div'); + item.className = 'app-notification-item' + (n.is_read === 0 ? ' app-notification-item--unread' : ''); + item.dataset.notificationId = n.id; + + const iconClass = TYPE_ICONS[n.type] || 'bi-bell'; + const bodyHtml = n.body ? `

    ${escapeHtml(n.body)}

    ` : ''; + + item.innerHTML = + `` + + `
    ` + + `

    ${escapeHtml(n.title)}

    ` + + bodyHtml + + `${escapeHtml(timeAgo(n.created))}` + + `
    ` + + `
    ` + + (n.is_read === 0 ? `` : '') + + `` + + `
    `; + + // Click on item navigates if link exists + item.addEventListener('click', (e) => { + if (e.target.closest('[data-action]')) return; + if (n.link) { + if (n.is_read === 0) markRead(n.id); + const base = document.querySelector('base')?.getAttribute('href') || '/'; + window.location.href = base + n.link; + } + }, { signal }); + + // Action buttons + item.addEventListener('click', (e) => { + const actionBtn = e.target.closest('[data-action]'); + if (!actionBtn) return; + e.stopPropagation(); + const action = actionBtn.dataset.action; + if (action === 'mark-read') markRead(n.id); + if (action === 'dismiss') dismiss(n.id); + }, { signal }); + + return item; + } + + // --- API --- + async function loadNotifications() { + try { + const data = await fetchJson('notifications/list-data', { signal }); + if (data.ok) { + renderNotifications(data.notifications || []); + updateBadge(data.unread_count ?? 0); + } + } catch { /* swallow */ } + } + + async function pollUnreadCount() { + try { + const data = await fetchJson('notifications/unread-count-data', { signal }); + if (data.ok) { + updateBadge(data.unread_count ?? 0); + } + } catch { /* swallow */ } + } + + async function markRead(id) { + try { + const csrf = getCsrf(); + const body = new URLSearchParams(); + body.set('id', String(id)); + if (csrf.key) body.set(csrf.key, csrf.token); + + const data = await fetchJson('notifications/mark-read-data', { + method: 'POST', + body, + signal, + }); + if (data.ok) { + updateBadge(data.unread_count ?? 0); + if (details.open) loadNotifications(); + } + } catch { /* swallow */ } + } + + async function markAllRead() { + try { + const csrf = getCsrf(); + const body = new URLSearchParams(); + body.set('all', '1'); + if (csrf.key) body.set(csrf.key, csrf.token); + + const data = await fetchJson('notifications/mark-read-data', { + method: 'POST', + body, + signal, + }); + if (data.ok) { + updateBadge(data.unread_count ?? 0); + if (details.open) loadNotifications(); + } + } catch { /* swallow */ } + } + + async function dismiss(id) { + try { + const csrf = getCsrf(); + const body = new URLSearchParams(); + body.set('id', String(id)); + if (csrf.key) body.set(csrf.key, csrf.token); + + const data = await fetchJson('notifications/delete-data', { + method: 'POST', + body, + signal, + }); + if (data.ok) { + updateBadge(data.unread_count ?? 0); + const el = list?.querySelector(`[data-notification-id="${id}"]`); + if (el) { + el.remove(); + if (list && !list.querySelector('.app-notification-item') && emptyEl) { + emptyEl.style.display = ''; + } + } + } + } catch { /* swallow */ } + } + + // --- Events --- + details.addEventListener('toggle', () => { + if (details.open) loadNotifications(); + }, { signal }); + + if (markAllBtn) { + markAllBtn.addEventListener('click', (e) => { + e.stopPropagation(); + markAllRead(); + }, { signal }); + } + + // --- Polling --- + function startPolling() { + stopPolling(); + pollTimer = setInterval(() => { + if (document.visibilityState !== 'hidden') { + pollUnreadCount(); + } + }, POLL_INTERVAL_MS); + } + + function stopPolling() { + if (pollTimer !== null) { + clearInterval(pollTimer); + pollTimer = null; + } + } + + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') { + pollUnreadCount(); + startPolling(); + } else { + stopPolling(); + } + }, { signal }); + + startPolling(); + + return { + destroy() { + ac.abort(); + stopPolling(); + }, + }; +} diff --git a/web/modules/notifications b/web/modules/notifications new file mode 120000 index 0000000..61934b1 --- /dev/null +++ b/web/modules/notifications @@ -0,0 +1 @@ +/var/www/modules/notifications/web \ No newline at end of file