forked from fa/breadcrumb-the-shire
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:
@@ -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'],
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
44
modules/notifications/pages/notifications/delete-data().php
Normal file
44
modules/notifications/pages/notifications/delete-data().php
Normal 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]);
|
||||
34
modules/notifications/pages/notifications/list-data().php
Normal file
34
modules/notifications/pages/notifications/list-data().php
Normal 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,
|
||||
]);
|
||||
@@ -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]);
|
||||
@@ -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]);
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
264
modules/notifications/web/js/components/app-notification-bell.js
Normal file
264
modules/notifications/web/js/components/app-notification-bell.js
Normal 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
1
web/modules/notifications
Symbolic link
@@ -0,0 +1 @@
|
||||
/var/www/modules/notifications/web
|
||||
Reference in New Issue
Block a user