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) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 22:09:44 +01:00
parent 1f0b1caf54
commit fb6e30a062
27 changed files with 1632 additions and 1 deletions

View File

@@ -12,5 +12,5 @@
* Each entry must match a directory name under modules/ containing a module.php manifest. * Each entry must match a directory name under modules/ containing a module.php manifest.
*/ */
return [ return [
'enabled_modules' => ['addressbook', 'bookmarks'], 'enabled_modules' => ['addressbook', 'bookmarks', 'notifications'],
]; ];

View File

@@ -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 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; ) 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` ( CREATE TABLE IF NOT EXISTS `tenant_custom_field_definitions` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`uuid` CHAR(36) NOT NULL, `uuid` CHAR(36) NOT NULL,

View File

@@ -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); $deleted = $this->userWriteRepository->delete($userId);
if (!$deleted) { if (!$deleted) {
return ['ok' => false, 'status' => 500, 'error' => 'delete_failed']; return ['ok' => false, 'status' => 500, 'error' => 'delete_failed'];
@@ -118,6 +124,8 @@ class UserAccountService
'user_id' => $userId, 'user_id' => $userId,
'uuid' => (string) ($user['uuid'] ?? ''), 'uuid' => (string) ($user['uuid'] ?? ''),
'actor_user_id' => $currentUserId, 'actor_user_id' => $currentUserId,
'display_name' => trim((string) ($user['display_name'] ?? '')),
'tenant_ids' => $preDeletionTenantIds,
]); ]);
return ['ok' => true, 'user' => $user]; return ['ok' => true, 'user' => $user];

View File

@@ -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;

View File

@@ -0,0 +1,43 @@
<?php
namespace MintyPHP\Module\Notifications\Handler;
use MintyPHP\Module\Notifications\Service\NotificationService;
use MintyPHP\Service\Scheduler\Handler\ScheduledJobHandlerInterface;
class NotificationCleanupJobHandler implements ScheduledJobHandlerInterface
{
public function __construct(private readonly NotificationService $notificationService)
{
}
public function definition(): array
{
return [
'label' => '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,
],
];
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace MintyPHP\Module\Notifications\Listeners;
use MintyPHP\App\Module\Contracts\EventListener;
use MintyPHP\Module\Notifications\Service\NotificationService;
use MintyPHP\Repository\Tenant\UserTenantRepositoryInterface;
use MintyPHP\Repository\User\UserReadRepositoryInterface;
use MintyPHP\Service\Access\PermissionService;
final class UserCreatedNotificationListener implements EventListener
{
public function __construct(
private readonly NotificationService $notificationService,
private readonly UserReadRepositoryInterface $userReadRepository,
private readonly UserTenantRepositoryInterface $userTenantRepository
) {
}
public function handle(string $event, array $payload): void
{
$userId = (int) ($payload['user_id'] ?? 0);
$uuid = (string) ($payload['uuid'] ?? '');
$actorUserId = isset($payload['actor_user_id']) ? (int) $payload['actor_user_id'] : null;
if ($userId <= 0 || $uuid === '') {
return;
}
$user = $this->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
);
}
}
}

View File

@@ -0,0 +1,65 @@
<?php
namespace MintyPHP\Module\Notifications\Listeners;
use MintyPHP\App\Module\Contracts\EventListener;
use MintyPHP\Module\Notifications\Service\NotificationService;
use MintyPHP\Repository\User\UserReadRepositoryInterface;
use MintyPHP\Service\Access\PermissionService;
final class UserDeletedNotificationListener implements EventListener
{
public function __construct(
private readonly NotificationService $notificationService,
private readonly UserReadRepositoryInterface $userReadRepository
) {
}
public function handle(string $event, array $payload): void
{
$userId = (int) ($payload['user_id'] ?? 0);
if ($userId <= 0) {
return;
}
// Belt-and-suspenders cleanup (DB CASCADE handles this too)
$this->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
);
}
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace MintyPHP\Module\Notifications;
use MintyPHP\Service\Access\AuthorizationDecision;
use MintyPHP\Service\Access\AuthorizationPolicyInterface;
final class NotificationsAuthorizationPolicy implements AuthorizationPolicyInterface
{
public const ABILITY_VIEW = 'notifications.view';
public const PERMISSION_KEY = 'notifications.view';
public function supports(string $ability): bool
{
return $ability === self::ABILITY_VIEW;
}
public function authorize(string $ability, array $context = []): AuthorizationDecision
{
$actorUserId = (int) ($context['actor_user_id'] ?? 0);
if ($actorUserId <= 0) {
return AuthorizationDecision::deny(403, 'forbidden');
}
return AuthorizationDecision::allow();
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace MintyPHP\Module\Notifications;
use MintyPHP\App\AppContainer;
use MintyPHP\App\Container\ContainerRegistrar;
use MintyPHP\Module\Notifications\Handler\NotificationCleanupJobHandler;
use MintyPHP\Module\Notifications\Listeners\UserCreatedNotificationListener;
use MintyPHP\Module\Notifications\Listeners\UserDeletedNotificationListener;
use MintyPHP\Module\Notifications\Service\NotificationRepositoryFactory;
use MintyPHP\Module\Notifications\Service\NotificationService;
use MintyPHP\Module\Notifications\Service\NotificationServicesFactory;
use MintyPHP\Repository\Tenant\UserTenantRepositoryInterface;
use MintyPHP\Repository\User\UserReadRepositoryInterface;
final class NotificationsContainerRegistrar implements ContainerRegistrar
{
public function register(AppContainer $container): void
{
$container->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)
));
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace MintyPHP\Module\Notifications\Providers;
use MintyPHP\App\AppContainer;
use MintyPHP\App\Module\Contracts\LayoutContextProvider;
final class NotificationsLayoutProvider implements LayoutContextProvider
{
public function provide(array $session, AppContainer $container): array
{
return [
'notifications.nav' => [
'unread_count' => (int) ($session['module.notifications.unread_count'] ?? 0),
],
];
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace MintyPHP\Module\Notifications\Providers;
use MintyPHP\App\AppContainer;
use MintyPHP\App\Module\Contracts\SessionProvider;
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Module\Notifications\Service\NotificationService;
final class NotificationsSessionProvider implements SessionProvider
{
private const SESSION_KEY = 'module.notifications.unread_count';
public function populate(array $user, AppContainer $container): void
{
$userId = (int) ($user['id'] ?? 0);
if ($userId <= 0) {
unset($_SESSION[self::SESSION_KEY]);
return;
}
$sessionStore = $container->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]);
}
}

View File

@@ -0,0 +1,135 @@
<?php
namespace MintyPHP\Module\Notifications\Repository;
use MintyPHP\DB;
use MintyPHP\Repository\Support\RepositoryArrayHelper;
class NotificationRepository implements NotificationRepositoryInterface
{
private function unwrapList(mixed $rows): array
{
return RepositoryArrayHelper::unwrapList($rows, 'user_notifications');
}
public function create(array $data): int|false
{
$result = DB::insert(
'insert into user_notifications (recipient_user_id, tenant_id, type, title, body, link, data, created) values (?, ?, ?, ?, ?, ?, ?, NOW())',
(string) ($data['recipient_user_id'] ?? 0),
$data['tenant_id'] !== null ? (string) $data['tenant_id'] : null,
(string) ($data['type'] ?? ''),
(string) ($data['title'] ?? ''),
$data['body'] ?? null,
$data['link'] ?? null,
isset($data['data']) ? json_encode($data['data'], JSON_THROW_ON_ERROR) : null
);
return is_int($result) && $result > 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;
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace MintyPHP\Module\Notifications\Repository;
interface NotificationRepositoryInterface
{
/** @return int|false Insert ID on success, false on failure */
public function create(array $data): int|false;
/**
* @return list<array<string, mixed>>
*/
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;
}

View File

@@ -0,0 +1,16 @@
<?php
namespace MintyPHP\Module\Notifications\Service;
use MintyPHP\Module\Notifications\Repository\NotificationRepository;
use MintyPHP\Module\Notifications\Repository\NotificationRepositoryInterface;
class NotificationRepositoryFactory
{
private ?NotificationRepository $notificationRepository = null;
public function createNotificationRepository(): NotificationRepositoryInterface
{
return $this->notificationRepository ??= new NotificationRepository();
}
}

View File

@@ -0,0 +1,203 @@
<?php
namespace MintyPHP\Module\Notifications\Service;
use MintyPHP\Module\Notifications\Repository\NotificationRepositoryInterface;
class NotificationService
{
public function __construct(
private readonly NotificationRepositoryInterface $notificationRepository
) {
}
/** @return list<array<string, mixed>> */
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<int, mixed> $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<int>
*/
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));
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace MintyPHP\Module\Notifications\Service;
class NotificationServicesFactory
{
private ?NotificationService $notificationService = null;
public function __construct(
private readonly NotificationRepositoryFactory $notificationRepositoryFactory
) {
}
public function createNotificationService(): NotificationService
{
return $this->notificationService ??= new NotificationService(
$this->notificationRepositoryFactory->createNotificationRepository()
);
}
}

View File

@@ -0,0 +1,44 @@
<?php
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Module\Notifications\Service\NotificationService;
use MintyPHP\Router;
use MintyPHP\Session;
use MintyPHP\Support\Guard;
Guard::requireLogin();
if (requestInput()->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]);

View File

@@ -0,0 +1,34 @@
<?php
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Module\Notifications\Service\NotificationService;
use MintyPHP\Router;
use MintyPHP\Support\Guard;
Guard::requireLogin();
if (requestInput()->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,
]);

View File

@@ -0,0 +1,48 @@
<?php
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Module\Notifications\Service\NotificationService;
use MintyPHP\Router;
use MintyPHP\Session;
use MintyPHP\Support\Guard;
Guard::requireLogin();
if (requestInput()->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]);

View File

@@ -0,0 +1,29 @@
<?php
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Module\Notifications\Service\NotificationService;
use MintyPHP\Router;
use MintyPHP\Support\Guard;
Guard::requireLogin();
if (requestInput()->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]);

View File

@@ -0,0 +1,40 @@
<?php
$layoutNav = is_array($layoutNav ?? null) ? $layoutNav : [];
$notifNav = is_array($layoutNav['notifications.nav'] ?? null) ? $layoutNav['notifications.nav'] : [];
$unreadCount = (int) ($notifNav['unread_count'] ?? 0);
?>
<li>
<details class="dropdown app-topbar-notification-menu" data-notification-bell>
<summary
aria-label="<?php e(t('Notifications')); ?>"
>
<i class="bi bi-bell"></i>
<?php if ($unreadCount > 0): ?>
<span class="app-notification-badge" data-notification-badge><?php e($unreadCount > 99 ? '99+' : $unreadCount); ?></span>
<?php else: ?>
<span class="app-notification-badge" data-notification-badge style="display:none;"></span>
<?php endif; ?>
</summary>
<ul class="app-topbar-menu-list app-topbar-menu-list--right app-notification-dropdown" data-notification-dropdown
data-label-dismiss="<?php e(t('Dismiss')); ?>"
data-label-mark-read="<?php e(t('Mark as read')); ?>">
<li class="app-notification-dropdown__header">
<span class="app-notification-dropdown__title"><?php e(t('Notifications')); ?></span>
<button type="button" class="app-notification-dropdown__mark-all" data-notification-mark-all>
<?php e(t('Mark all as read')); ?>
</button>
</li>
<li class="app-notification-dropdown__body" data-notification-list>
<?php
$emptyState = [
'message' => t('No notifications'),
'size' => 'compact',
'icon' => false,
];
require __DIR__ . '/../../../templates/partials/app-empty-state.phtml';
?>
</li>
</ul>
</details>
</li>

View File

@@ -0,0 +1,145 @@
<?php
namespace MintyPHP\Tests\Module\Notifications\Listeners;
use MintyPHP\Module\Notifications\Listeners\UserCreatedNotificationListener;
use MintyPHP\Module\Notifications\Service\NotificationService;
use MintyPHP\Repository\Tenant\UserTenantRepositoryInterface;
use MintyPHP\Repository\User\UserReadRepositoryInterface;
use MintyPHP\Service\Access\PermissionService;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
class UserCreatedNotificationListenerTest extends TestCase
{
private NotificationService&MockObject $notifService;
private UserReadRepositoryInterface&MockObject $userRepo;
private UserTenantRepositoryInterface&MockObject $utRepo;
private UserCreatedNotificationListener $listener;
protected function setUp(): void
{
$this->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,
]);
}
}

View File

@@ -0,0 +1,129 @@
<?php
namespace MintyPHP\Tests\Module\Notifications\Service;
use MintyPHP\Module\Notifications\Repository\NotificationRepositoryInterface;
use MintyPHP\Module\Notifications\Service\NotificationService;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
class NotificationServiceTest extends TestCase
{
private NotificationRepositoryInterface&MockObject $notifRepo;
private NotificationService $service;
protected function setUp(): void
{
$this->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));
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,264 @@
/**
* Notification bell runtime component.
*
* Works with the native <details> 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 ? `<p class="app-notification-item__body">${escapeHtml(n.body)}</p>` : '';
item.innerHTML =
`<span class="app-notification-item__icon"><i class="bi ${escapeHtml(iconClass)}"></i></span>` +
`<div class="app-notification-item__content">` +
`<p class="app-notification-item__title">${escapeHtml(n.title)}</p>` +
bodyHtml +
`<span class="app-notification-item__time">${escapeHtml(timeAgo(n.created))}</span>` +
`</div>` +
`<div class="app-notification-item__actions">` +
(n.is_read === 0 ? `<button type="button" class="app-notification-item__action-btn" data-action="mark-read" title="${escapeHtml(dropdown.dataset.labelMarkRead || '')}"><i class="bi bi-check2"></i></button>` : '') +
`<button type="button" class="app-notification-item__action-btn" data-action="dismiss" title="${escapeHtml(dropdown.dataset.labelDismiss || '')}"><i class="bi bi-x"></i></button>` +
`</div>`;
// 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();
},
};
}

1
web/modules/notifications Symbolic link
View File

@@ -0,0 +1 @@
/var/www/modules/notifications/web