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:
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user