refactor(audit): extract audit domain into self-contained module

Move the entire audit subsystem (system audit, API audit, import audit,
user lifecycle audit, frontend telemetry) from core into modules/audit/.

Core decoupling via interface-based injection:
- AuditRecorderInterface replaces SystemAuditService in 10+ core services
- UserLifecycleAuditInterface / ImportAuditInterface for specialized flows
- NullAuditRecorder fallback when audit module is disabled
- ApiBootstrap/ApiResponse use null-safe callable resolvers

Module structure (modules/audit/):
- Manifest with routes, permissions, scheduler jobs, authorization policy
- 9 services, 8 repositories, 6 domain enums, 4 job handlers
- 33 page files, 4 JS files, 8 test files, migration scripts, i18n

Core cleanup:
- OperationsAuthorizationPolicy, UiCapabilityMap, PermissionService
  surgically cleaned of audit-specific constants
- Sidebar template cleared of hardcoded audit navigation
- AuditModuleIsolationContractTest ensures no future core→module coupling

All quality gates pass: 1346 tests (19,276 assertions), PHPStan level 5
clean, architecture contracts verified.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-25 21:12:49 +01:00
parent 12a837bda9
commit 0c351f6aff
176 changed files with 2157 additions and 834 deletions

View File

@@ -3,6 +3,7 @@
namespace MintyPHP\Module\Notifications\Listeners;
use MintyPHP\App\Module\Contracts\EventListener;
use MintyPHP\Module\Notifications\Service\NotificationMessage;
use MintyPHP\Module\Notifications\Service\NotificationService;
use MintyPHP\Repository\Tenant\UserTenantRepositoryInterface;
use MintyPHP\Repository\User\UserReadRepositoryInterface;
@@ -58,17 +59,21 @@ final class UserActivatedNotificationListener implements EventListener
$adminSet = array_fill_keys(array_map('intval', $adminUserIds), true);
$actorUserId = isset($payload['actor_user_id']) ? (int) $payload['actor_user_id'] : null;
$title = sprintf(t('User activated: %s'), $displayName);
$link = $uuid !== '' ? 'admin/users/edit/' . $uuid : null;
$message = NotificationMessage::localized(
'user.activated',
'User activated: %s',
[$displayName],
null,
[],
$link,
['user_id' => $userId, 'uuid' => $uuid]
);
foreach ($tenantIds as $tenantId) {
$this->notificationService->createForTenantAdminUsers(
$this->notificationService->createForTenantAdminUsersFromMessage(
$tenantId,
'user.activated',
$title,
null,
$link,
['user_id' => $userId, 'uuid' => $uuid],
$message,
$actorUserId,
$adminSet
);

View File

@@ -3,6 +3,7 @@
namespace MintyPHP\Module\Notifications\Listeners;
use MintyPHP\App\Module\Contracts\EventListener;
use MintyPHP\Module\Notifications\Service\NotificationMessage;
use MintyPHP\Module\Notifications\Service\NotificationService;
use MintyPHP\Repository\User\UserReadRepositoryInterface;
use MintyPHP\Service\Access\PermissionService;
@@ -42,24 +43,26 @@ final class UserAssignmentChangedNotificationListener implements EventListener
$tenantIds = $this->sanitizeTenantIds($payload['tenant_ids'] ?? []);
$changes = is_array($payload['changes'] ?? null) ? $payload['changes'] : [];
$title = sprintf(t('User assignments updated: %s'), $displayName);
$body = t('Tenant, role, or department assignments were changed.');
$link = $uuid !== '' ? 'admin/users/edit/' . $uuid : null;
$data = [
$message = NotificationMessage::localized(
'user.assignment_changed',
'User assignments updated: %s',
[$displayName],
'Tenant, role, or department assignments were changed.',
[],
$link,
[
'user_id' => $userId,
'uuid' => $uuid,
'changes' => $changes,
];
]
);
if ($actorUserId === null || $actorUserId !== $userId) {
$this->notificationService->createForUser(
$this->notificationService->createFromMessage(
$userId,
null,
'user.assignment_changed',
$title,
$body,
$link,
$data
$message
);
}
@@ -76,13 +79,9 @@ final class UserAssignmentChangedNotificationListener implements EventListener
$adminSet = array_fill_keys(array_map('intval', $adminUserIds), true);
foreach ($tenantIds as $tenantId) {
$this->notificationService->createForTenantAdminUsers(
$this->notificationService->createForTenantAdminUsersFromMessage(
$tenantId,
'user.assignment_changed',
$title,
$body,
$link,
$data,
$message,
$actorUserId,
$adminSet
);

View File

@@ -3,6 +3,7 @@
namespace MintyPHP\Module\Notifications\Listeners;
use MintyPHP\App\Module\Contracts\EventListener;
use MintyPHP\Module\Notifications\Service\NotificationMessage;
use MintyPHP\Module\Notifications\Service\NotificationService;
use MintyPHP\Repository\Tenant\UserTenantRepositoryInterface;
use MintyPHP\Repository\User\UserReadRepositoryInterface;
@@ -51,17 +52,21 @@ final class UserCreatedNotificationListener implements EventListener
}
$adminSet = array_flip($adminUserIds);
$title = sprintf(t('New user: %s'), $displayName);
$link = 'admin/users/edit/' . $uuid;
$message = NotificationMessage::localized(
'user.created',
'New user: %s',
[$displayName],
null,
[],
$link,
['user_id' => $userId, 'uuid' => $uuid]
);
foreach ($tenantIds as $tenantId) {
$this->notificationService->createForTenantAdminUsers(
$this->notificationService->createForTenantAdminUsersFromMessage(
$tenantId,
'user.created',
$title,
null,
$link,
['user_id' => $userId, 'uuid' => $uuid],
$message,
$actorUserId,
$adminSet
);

View File

@@ -3,6 +3,7 @@
namespace MintyPHP\Module\Notifications\Listeners;
use MintyPHP\App\Module\Contracts\EventListener;
use MintyPHP\Module\Notifications\Service\NotificationMessage;
use MintyPHP\Module\Notifications\Service\NotificationService;
use MintyPHP\Repository\Tenant\UserTenantRepositoryInterface;
use MintyPHP\Repository\User\UserReadRepositoryInterface;
@@ -58,17 +59,21 @@ final class UserDeactivatedNotificationListener implements EventListener
$adminSet = array_fill_keys(array_map('intval', $adminUserIds), true);
$actorUserId = isset($payload['actor_user_id']) ? (int) $payload['actor_user_id'] : null;
$title = sprintf(t('User deactivated: %s'), $displayName);
$link = $uuid !== '' ? 'admin/users/edit/' . $uuid : null;
$message = NotificationMessage::localized(
'user.deactivated',
'User deactivated: %s',
[$displayName],
null,
[],
$link,
['user_id' => $userId, 'uuid' => $uuid]
);
foreach ($tenantIds as $tenantId) {
$this->notificationService->createForTenantAdminUsers(
$this->notificationService->createForTenantAdminUsersFromMessage(
$tenantId,
'user.deactivated',
$title,
null,
$link,
['user_id' => $userId, 'uuid' => $uuid],
$message,
$actorUserId,
$adminSet
);

View File

@@ -3,6 +3,7 @@
namespace MintyPHP\Module\Notifications\Listeners;
use MintyPHP\App\Module\Contracts\EventListener;
use MintyPHP\Module\Notifications\Service\NotificationMessage;
use MintyPHP\Module\Notifications\Service\NotificationService;
use MintyPHP\Repository\User\UserReadRepositoryInterface;
use MintyPHP\Service\Access\PermissionService;
@@ -42,7 +43,15 @@ final class UserDeletedNotificationListener implements EventListener
}
$adminSet = array_flip($adminUserIds);
$title = sprintf(t('User deleted: %s'), $displayName);
$message = NotificationMessage::localized(
'user.deleted',
'User deleted: %s',
[$displayName],
null,
[],
null,
['user_id' => $userId]
);
foreach ($tenantIds as $tenantId) {
$tenantId = (int) $tenantId;
@@ -50,13 +59,9 @@ final class UserDeletedNotificationListener implements EventListener
continue;
}
$this->notificationService->createForTenantAdminUsers(
$this->notificationService->createForTenantAdminUsersFromMessage(
$tenantId,
'user.deleted',
$title,
null,
null,
['user_id' => $userId],
$message,
$actorUserId,
$adminSet
);

View File

@@ -49,7 +49,7 @@ class NotificationRepository implements NotificationRepositoryInterface
$tenantParams = [];
$rows = DB::select(
'select id, type, title, body, link, is_read, created from user_notifications where recipient_user_id = ?' .
'select id, type, title, body, link, data, is_read, created from user_notifications where recipient_user_id = ?' .
$this->tenantScopeClause($tenantId, true, $tenantParams) .
' order by created desc limit ' . $limit . ' offset ' . $offset,
(string) $userId,

View File

@@ -7,6 +7,7 @@ use MintyPHP\Repository\Tenant\UserTenantRepositoryInterface;
class NotificationService
{
private const MESSAGE_META_KEY = '__message';
private const DEDUPE_WINDOW_MINUTES = 30;
private const DEDUPE_TYPES = [
'user.created' => true,
@@ -29,7 +30,8 @@ class NotificationService
return [];
}
return $this->notificationRepository->listByUser($userId, $tenantId, $limit, 0);
$notifications = $this->notificationRepository->listByUser($userId, $tenantId, $limit, 0);
return $this->localizeNotifications($notifications);
}
public function unreadCount(int $userId, ?int $tenantId = null): int
@@ -77,23 +79,8 @@ class NotificationService
?string $link,
array $data
): int|false {
if ($recipientUserId <= 0 || $type === '' || $title === '') {
return false;
}
$dedupeMeta = $this->buildDedupeMeta($recipientUserId, $tenantId, $type, $data);
return $this->notificationRepository->create([
'recipient_user_id' => $recipientUserId,
'tenant_id' => $tenantId,
'type' => $type,
'title' => $title,
'body' => $body,
'link' => $link,
'data' => $data ?: null,
'dedupe_fingerprint' => $dedupeMeta['fingerprint'] ?? null,
'dedupe_bucket' => $dedupeMeta['bucket'] ?? null,
]);
$message = NotificationMessage::plain($type, $title, $body, $link, $data);
return $this->createFromMessage($recipientUserId, $tenantId, $message);
}
/**
@@ -110,25 +97,8 @@ class NotificationService
array $data,
?int $excludeUserId
): int {
if ($tenantId <= 0 || $type === '' || $title === '') {
return 0;
}
$userIds = $this->userTenantRepository->listActiveUserIdsByTenantId($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;
$message = NotificationMessage::plain($type, $title, $body, $link, $data);
return $this->createForTenantUsersFromMessage($tenantId, $message, $excludeUserId);
}
/**
@@ -147,7 +117,69 @@ class NotificationService
?int $excludeUserId,
array $allowedUserIds
): int {
if ($tenantId <= 0 || $type === '' || $title === '' || $allowedUserIds === []) {
$message = NotificationMessage::plain($type, $title, $body, $link, $data);
return $this->createForTenantAdminUsersFromMessage($tenantId, $message, $excludeUserId, $allowedUserIds);
}
public function createFromMessage(int $recipientUserId, ?int $tenantId, NotificationMessage $message): int|false
{
$type = trim($message->type());
$title = trim($message->title());
if ($recipientUserId <= 0 || $type === '' || $title === '') {
return false;
}
$persistedData = $this->buildPersistedData($message);
$dedupeMeta = $this->buildDedupeMeta($recipientUserId, $tenantId, $type, $persistedData);
return $this->notificationRepository->create([
'recipient_user_id' => $recipientUserId,
'tenant_id' => $tenantId,
'type' => $type,
'title' => $title,
'body' => $message->body(),
'link' => $message->link(),
'data' => $persistedData !== [] ? $persistedData : null,
'dedupe_fingerprint' => $dedupeMeta['fingerprint'] ?? null,
'dedupe_bucket' => $dedupeMeta['bucket'] ?? null,
]);
}
public function createForTenantUsersFromMessage(int $tenantId, NotificationMessage $message, ?int $excludeUserId): int
{
if ($tenantId <= 0 || trim($message->type()) === '' || trim($message->title()) === '') {
return 0;
}
$userIds = $this->userTenantRepository->listActiveUserIdsByTenantId($tenantId);
$created = 0;
foreach ($userIds as $userId) {
if ($excludeUserId !== null && $userId === $excludeUserId) {
continue;
}
$result = $this->createFromMessage($userId, $tenantId, $message);
if ($result !== false) {
$created++;
}
}
return $created;
}
/**
* Fan-out using a typed message contract for tenant users filtered by allowed user IDs.
*
* @param array<int, mixed> $allowedUserIds Flipped array (user ID as key) for fast lookup
*/
public function createForTenantAdminUsersFromMessage(
int $tenantId,
NotificationMessage $message,
?int $excludeUserId,
array $allowedUserIds
): int {
if ($tenantId <= 0 || trim($message->type()) === '' || trim($message->title()) === '' || $allowedUserIds === []) {
return 0;
}
@@ -162,7 +194,7 @@ class NotificationService
continue;
}
$result = $this->createForUser($userId, $tenantId, $type, $title, $body, $link, $data);
$result = $this->createFromMessage($userId, $tenantId, $message);
if ($result !== false) {
$created++;
}
@@ -237,4 +269,152 @@ class NotificationService
return '';
}
/**
* @param list<array<string, mixed>> $notifications
* @return list<array<string, mixed>>
*/
private function localizeNotifications(array $notifications): array
{
$localized = [];
foreach ($notifications as $notification) {
$localized[] = $this->localizeNotification($notification);
}
return $localized;
}
/**
* @param array<string, mixed> $notification
* @return array<string, mixed>
*/
private function localizeNotification(array $notification): array
{
$data = $this->decodeNotificationData($notification['data'] ?? null);
$messageMeta = $this->extractMessageMeta($data);
if ($messageMeta !== []) {
if ($messageMeta['title_key'] !== '') {
$notification['title'] = $this->translateWithParams(
$messageMeta['title_key'],
$messageMeta['title_params']
);
}
if ($messageMeta['body_key'] !== '') {
$notification['body'] = $this->translateWithParams(
$messageMeta['body_key'],
$messageMeta['body_params']
);
}
}
if ($data !== []) {
unset($data[self::MESSAGE_META_KEY]);
if ($data !== []) {
$notification['data'] = $data;
} else {
unset($notification['data']);
}
} else {
unset($notification['data']);
}
return $notification;
}
/**
* @param array<string, mixed> $data
* @return array{title_key: string, title_params: list<scalar|null>, body_key: string, body_params: list<scalar|null>}|array{}
*/
private function extractMessageMeta(array $data): array
{
$meta = $data[self::MESSAGE_META_KEY] ?? null;
if (!is_array($meta)) {
return [];
}
$titleKey = trim((string) ($meta['title_key'] ?? ''));
$bodyKey = trim((string) ($meta['body_key'] ?? ''));
if ($titleKey === '' && $bodyKey === '') {
return [];
}
return [
'title_key' => $titleKey,
'title_params' => $this->normalizeMessageParams($meta['title_params'] ?? []),
'body_key' => $bodyKey,
'body_params' => $this->normalizeMessageParams($meta['body_params'] ?? []),
];
}
/**
* @param list<scalar|null> $params
*/
private function translateWithParams(string $key, array $params): string
{
return (string) t($key, ...$params);
}
/**
* @return list<scalar|null>
*/
private function normalizeMessageParams(mixed $params): array
{
if (!is_array($params)) {
return [];
}
$normalized = [];
foreach ($params as $value) {
if (is_scalar($value) || $value === null) {
$normalized[] = $value;
}
}
return array_values($normalized);
}
/**
* @return array<string, mixed>
*/
private function decodeNotificationData(mixed $raw): array
{
if (is_array($raw)) {
return $raw;
}
if (!is_string($raw) || trim($raw) === '') {
return [];
}
try {
$decoded = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
} catch (\JsonException) {
return [];
}
return is_array($decoded) ? $decoded : [];
}
/**
* @return array<string, mixed>
*/
private function buildPersistedData(NotificationMessage $message): array
{
$data = $message->data();
$titleKey = trim((string) ($message->titleKey() ?? ''));
$bodyKey = trim((string) ($message->bodyKey() ?? ''));
if ($titleKey !== '' || $bodyKey !== '') {
$data[self::MESSAGE_META_KEY] = [
'title_key' => $titleKey,
'title_params' => $this->normalizeMessageParams($message->titleParams()),
'body_key' => $bodyKey,
'body_params' => $this->normalizeMessageParams($message->bodyParams()),
];
}
return $data;
}
}

View File

@@ -3,6 +3,7 @@
namespace MintyPHP\Tests\Module\Notifications\Listeners;
use MintyPHP\Module\Notifications\Listeners\UserActivatedNotificationListener;
use MintyPHP\Module\Notifications\Service\NotificationMessage;
use MintyPHP\Module\Notifications\Service\NotificationService;
use MintyPHP\Repository\Tenant\UserTenantRepositoryInterface;
use MintyPHP\Repository\User\UserReadRepositoryInterface;
@@ -42,14 +43,15 @@ final class UserActivatedNotificationListenerTest extends TestCase
->willReturn([20, 30]);
$this->notificationService->expects($this->exactly(2))
->method('createForTenantAdminUsers')
->method('createForTenantAdminUsersFromMessage')
->with(
$this->callback(static fn (int $tenantId): bool => in_array($tenantId, [1, 2], true)),
'user.activated',
$this->stringContains('Alice'),
null,
'admin/users/edit/user-uuid',
['user_id' => 10, 'uuid' => 'user-uuid'],
$this->callback(function (NotificationMessage $message): bool {
return $message->type() === 'user.activated'
&& $message->link() === 'admin/users/edit/user-uuid'
&& $message->data() === ['user_id' => 10, 'uuid' => 'user-uuid']
&& $message->titleKey() === 'User activated: %s';
}),
5,
$this->isArray()
);
@@ -74,13 +76,9 @@ final class UserActivatedNotificationListenerTest extends TestCase
->willReturn([20]);
$this->notificationService->expects($this->once())
->method('createForTenantAdminUsers')
->method('createForTenantAdminUsersFromMessage')
->with(
7,
'user.activated',
$this->anything(),
null,
'admin/users/edit/user-uuid',
$this->anything(),
null,
$this->anything()
@@ -92,7 +90,7 @@ final class UserActivatedNotificationListenerTest extends TestCase
public function testHandleSkipsInvalidPayload(): void
{
$this->userReadRepository->expects($this->never())->method('find');
$this->notificationService->expects($this->never())->method('createForTenantAdminUsers');
$this->notificationService->expects($this->never())->method('createForTenantAdminUsersFromMessage');
$this->listener->handle('user.activated', ['user_id' => 0]);
}

View File

@@ -3,6 +3,7 @@
namespace MintyPHP\Tests\Module\Notifications\Listeners;
use MintyPHP\Module\Notifications\Listeners\UserAssignmentChangedNotificationListener;
use MintyPHP\Module\Notifications\Service\NotificationMessage;
use MintyPHP\Module\Notifications\Service\NotificationService;
use MintyPHP\Repository\User\UserReadRepositoryInterface;
use MintyPHP\Service\Access\PermissionService;
@@ -38,27 +39,24 @@ final class UserAssignmentChangedNotificationListenerTest extends TestCase
->willReturn([20, 30]);
$this->notificationService->expects($this->once())
->method('createForUser')
->method('createFromMessage')
->with(
10,
null,
'user.assignment_changed',
$this->stringContains('Alice'),
$this->isString(),
'admin/users/edit/user-uuid',
$this->isArray()
$this->callback(function (NotificationMessage $message): bool {
return $message->type() === 'user.assignment_changed'
&& $message->link() === 'admin/users/edit/user-uuid'
&& $message->titleKey() === 'User assignments updated: %s'
&& $message->bodyKey() === 'Tenant, role, or department assignments were changed.';
})
)
->willReturn(99);
$this->notificationService->expects($this->once())
->method('createForTenantAdminUsers')
->method('createForTenantAdminUsersFromMessage')
->with(
1,
'user.assignment_changed',
$this->stringContains('Alice'),
$this->isString(),
'admin/users/edit/user-uuid',
$this->isArray(),
$this->isInstanceOf(NotificationMessage::class),
7,
$this->isArray()
)
@@ -83,15 +81,11 @@ final class UserAssignmentChangedNotificationListenerTest extends TestCase
->method('listPrivilegedUserIdsByPermissionKeys')
->willReturn([20]);
$this->notificationService->expects($this->never())->method('createForUser');
$this->notificationService->expects($this->never())->method('createFromMessage');
$this->notificationService->expects($this->once())
->method('createForTenantAdminUsers')
->method('createForTenantAdminUsersFromMessage')
->with(
1,
'user.assignment_changed',
$this->anything(),
$this->anything(),
'admin/users/edit/user-uuid',
$this->anything(),
10,
$this->anything()
@@ -106,8 +100,8 @@ final class UserAssignmentChangedNotificationListenerTest extends TestCase
public function testHandleSkipsInvalidPayload(): void
{
$this->notificationService->expects($this->never())->method('createForUser');
$this->notificationService->expects($this->never())->method('createForTenantAdminUsers');
$this->notificationService->expects($this->never())->method('createFromMessage');
$this->notificationService->expects($this->never())->method('createForTenantAdminUsersFromMessage');
$this->listener->handle('user.assignment_changed', ['user_id' => 0]);
}

View File

@@ -3,6 +3,7 @@
namespace MintyPHP\Tests\Module\Notifications\Listeners;
use MintyPHP\Module\Notifications\Listeners\UserCreatedNotificationListener;
use MintyPHP\Module\Notifications\Service\NotificationMessage;
use MintyPHP\Module\Notifications\Service\NotificationService;
use MintyPHP\Repository\Tenant\UserTenantRepositoryInterface;
use MintyPHP\Repository\User\UserReadRepositoryInterface;
@@ -39,15 +40,16 @@ class UserCreatedNotificationListenerTest extends TestCase
->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 {
->method('createForTenantAdminUsersFromMessage')
->willReturnCallback(function (int $tenantId, NotificationMessage $message, ?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('user.created', $message->type());
$this->assertStringContainsString('Alice Smith', $message->title());
$this->assertSame('admin/users/edit/abc-uuid', $message->link());
$this->assertSame(5, $excludeUserId);
$this->assertArrayHasKey(20, $adminSet);
$this->assertArrayHasKey(30, $adminSet);
$this->assertSame('New user: %s', $message->titleKey());
return 2;
});
@@ -68,13 +70,9 @@ class UserCreatedNotificationListenerTest extends TestCase
$this->userRepo->expects($this->any())->method('listPrivilegedUserIdsByPermissionKeys')->willReturn([20]);
$this->notifService->expects($this->once())
->method('createForTenantAdminUsers')
->method('createForTenantAdminUsersFromMessage')
->with(
1,
'user.created',
$this->anything(),
null,
'admin/users/edit/xyz',
$this->anything(),
7, // actor excluded
$this->anything()
@@ -90,7 +88,7 @@ class UserCreatedNotificationListenerTest extends TestCase
public function testHandleSkipsInvalidPayload(): void
{
$this->notifService->expects($this->never())->method('createForTenantAdminUsers');
$this->notifService->expects($this->never())->method('createForTenantAdminUsersFromMessage');
$this->listener->handle('user.created', ['uuid' => 'abc']);
$this->listener->handle('user.created', ['user_id' => 0, 'uuid' => 'abc']);
@@ -100,7 +98,7 @@ class UserCreatedNotificationListenerTest extends TestCase
public function testHandleSkipsWhenUserNotFound(): void
{
$this->userRepo->expects($this->any())->method('find')->with(99)->willReturn(null);
$this->notifService->expects($this->never())->method('createForTenantAdminUsers');
$this->notifService->expects($this->never())->method('createForTenantAdminUsersFromMessage');
$this->listener->handle('user.created', [
'user_id' => 99,
@@ -116,7 +114,7 @@ class UserCreatedNotificationListenerTest extends TestCase
'display_name' => 'Alice',
]);
$this->utRepo->expects($this->any())->method('listTenantIdsByUserId')->with(10)->willReturn([]);
$this->notifService->expects($this->never())->method('createForTenantAdminUsers');
$this->notifService->expects($this->never())->method('createForTenantAdminUsersFromMessage');
$this->listener->handle('user.created', [
'user_id' => 10,
@@ -134,7 +132,7 @@ class UserCreatedNotificationListenerTest extends TestCase
$this->utRepo->expects($this->any())->method('listTenantIdsByUserId')->with(10)->willReturn([1]);
$this->userRepo->expects($this->any())->method('listPrivilegedUserIdsByPermissionKeys')->willReturn([]);
$this->notifService->expects($this->never())->method('createForTenantAdminUsers');
$this->notifService->expects($this->never())->method('createForTenantAdminUsersFromMessage');
$this->listener->handle('user.created', [
'user_id' => 10,

View File

@@ -3,6 +3,7 @@
namespace MintyPHP\Tests\Module\Notifications\Listeners;
use MintyPHP\Module\Notifications\Listeners\UserDeactivatedNotificationListener;
use MintyPHP\Module\Notifications\Service\NotificationMessage;
use MintyPHP\Module\Notifications\Service\NotificationService;
use MintyPHP\Repository\Tenant\UserTenantRepositoryInterface;
use MintyPHP\Repository\User\UserReadRepositoryInterface;
@@ -42,14 +43,15 @@ final class UserDeactivatedNotificationListenerTest extends TestCase
->willReturn([20, 30]);
$this->notificationService->expects($this->exactly(2))
->method('createForTenantAdminUsers')
->method('createForTenantAdminUsersFromMessage')
->with(
$this->callback(static fn (int $tenantId): bool => in_array($tenantId, [1, 2], true)),
'user.deactivated',
$this->stringContains('Alice'),
null,
'admin/users/edit/user-uuid',
['user_id' => 10, 'uuid' => 'user-uuid'],
$this->callback(function (NotificationMessage $message): bool {
return $message->type() === 'user.deactivated'
&& $message->link() === 'admin/users/edit/user-uuid'
&& $message->data() === ['user_id' => 10, 'uuid' => 'user-uuid']
&& $message->titleKey() === 'User deactivated: %s';
}),
5,
$this->isArray()
);
@@ -69,7 +71,7 @@ final class UserDeactivatedNotificationListenerTest extends TestCase
'display_name' => 'Alice',
]);
$this->userTenantRepository->expects($this->once())->method('listTenantIdsByUserId')->with(10)->willReturn([]);
$this->notificationService->expects($this->never())->method('createForTenantAdminUsers');
$this->notificationService->expects($this->never())->method('createForTenantAdminUsersFromMessage');
$this->listener->handle('user.deactivated', ['user_id' => 10]);
}

View File

@@ -3,6 +3,7 @@
namespace MintyPHP\Tests\Module\Notifications\Listeners;
use MintyPHP\Module\Notifications\Listeners\UserDeletedNotificationListener;
use MintyPHP\Module\Notifications\Service\NotificationMessage;
use MintyPHP\Module\Notifications\Service\NotificationService;
use MintyPHP\Repository\User\UserReadRepositoryInterface;
use MintyPHP\Service\Access\PermissionService;
@@ -34,15 +35,16 @@ class UserDeletedNotificationListenerTest extends TestCase
->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 {
->method('createForTenantAdminUsersFromMessage')
->willReturnCallback(function (int $tenantId, NotificationMessage $message, ?int $excludeUserId, array $adminSet): int {
$this->assertContains($tenantId, [1, 2]);
$this->assertSame('user.deleted', $type);
$this->assertStringContainsString('Alice', $title);
$this->assertNull($link);
$this->assertSame('user.deleted', $message->type());
$this->assertStringContainsString('Alice', $message->title());
$this->assertNull($message->link());
$this->assertSame(5, $excludeUserId);
$this->assertArrayHasKey(20, $adminSet);
$this->assertArrayHasKey(30, $adminSet);
$this->assertSame('User deleted: %s', $message->titleKey());
return 1;
});
@@ -57,7 +59,7 @@ class UserDeletedNotificationListenerTest extends TestCase
public function testHandleSkipsOnInvalidUserId(): void
{
$this->notifService->expects($this->never())->method('deleteAllForUser');
$this->notifService->expects($this->never())->method('createForTenantAdminUsers');
$this->notifService->expects($this->never())->method('createForTenantAdminUsersFromMessage');
$this->listener->handle('user.deleted', ['user_id' => 0]);
$this->listener->handle('user.deleted', []);
@@ -69,7 +71,7 @@ class UserDeletedNotificationListenerTest extends TestCase
->method('deleteAllForUser')
->with(10);
$this->notifService->expects($this->never())->method('createForTenantAdminUsers');
$this->notifService->expects($this->never())->method('createForTenantAdminUsersFromMessage');
$this->listener->handle('user.deleted', [
'user_id' => 10,
@@ -84,7 +86,7 @@ class UserDeletedNotificationListenerTest extends TestCase
->method('deleteAllForUser')
->with(10);
$this->notifService->expects($this->never())->method('createForTenantAdminUsers');
$this->notifService->expects($this->never())->method('createForTenantAdminUsersFromMessage');
$this->listener->handle('user.deleted', [
'user_id' => 10,
@@ -102,13 +104,9 @@ class UserDeletedNotificationListenerTest extends TestCase
->willReturn([20]);
$this->notifService->expects($this->once())
->method('createForTenantAdminUsers')
->method('createForTenantAdminUsersFromMessage')
->with(
1,
'user.deleted',
$this->anything(),
null,
null,
$this->anything(),
7, // actor excluded
$this->anything()
@@ -131,7 +129,7 @@ class UserDeletedNotificationListenerTest extends TestCase
->method('listPrivilegedUserIdsByPermissionKeys')
->willReturn([]);
$this->notifService->expects($this->never())->method('createForTenantAdminUsers');
$this->notifService->expects($this->never())->method('createForTenantAdminUsersFromMessage');
$this->listener->handle('user.deleted', [
'user_id' => 10,

View File

@@ -3,6 +3,7 @@
namespace MintyPHP\Tests\Module\Notifications\Service;
use MintyPHP\Module\Notifications\Repository\NotificationRepositoryInterface;
use MintyPHP\Module\Notifications\Service\NotificationMessage;
use MintyPHP\Module\Notifications\Service\NotificationService;
use MintyPHP\Repository\Tenant\UserTenantRepositoryInterface;
use PHPUnit\Framework\MockObject\MockObject;
@@ -282,4 +283,95 @@ class NotificationServiceTest extends TestCase
$result = $this->service->createForUser(5, 1, 'system', 'System', null, null, ['user_id' => 33]);
$this->assertSame(56, $result);
}
public function testCreateFromMessagePersistsLocalizationMetadata(): void
{
$message = NotificationMessage::localized(
'user.created',
'New user: %s',
['Alice'],
null,
[],
'admin/users/edit/abc',
['user_id' => 33]
);
$this->notifRepo->expects($this->once())
->method('create')
->with($this->callback(function (array $data): bool {
if (!isset($data['data']['__message']) || !is_array($data['data']['__message'])) {
return false;
}
return $data['type'] === 'user.created'
&& $data['data']['__message']['title_key'] === 'New user: %s'
&& ($data['data']['__message']['title_params'] ?? []) === ['Alice']
&& $data['data']['user_id'] === 33;
}))
->willReturn(57);
$result = $this->service->createFromMessage(5, 1, $message);
$this->assertSame(57, $result);
}
public function testCreateForTenantUsersFromMessageCreatesForAllEligibleUsers(): void
{
$message = NotificationMessage::plain('system', 'Hello', null, null, []);
$this->utRepo->expects($this->once())
->method('listActiveUserIdsByTenantId')
->with(1)
->willReturn([10, 20, 30]);
$this->notifRepo->expects($this->exactly(2))
->method('create')
->willReturn(1);
$result = $this->service->createForTenantUsersFromMessage(1, $message, 20);
$this->assertSame(2, $result);
}
public function testCreateForTenantAdminUsersFromMessageRejectsEmptyAllowedSet(): void
{
$message = NotificationMessage::plain('system', 'Hello', null, null, []);
$this->utRepo->expects($this->never())->method('listActiveUserIdsByTenantId');
$this->notifRepo->expects($this->never())->method('create');
$result = $this->service->createForTenantAdminUsersFromMessage(1, $message, null, []);
$this->assertSame(0, $result);
}
public function testListForUserLocalizesFromStoredMessageMetadata(): void
{
$this->notifRepo->expects($this->once())
->method('listByUser')
->with(3, null, 50, 0)
->willReturn([
[
'id' => 1,
'type' => 'user.created',
'title' => 'fallback',
'body' => null,
'link' => null,
'data' => json_encode([
'user_id' => 10,
'__message' => [
'title_key' => 'New user: %s',
'title_params' => ['Alice'],
'body_key' => '',
'body_params' => [],
],
], JSON_THROW_ON_ERROR),
'is_read' => 0,
'created' => '2026-03-19 10:00:00',
],
]);
$result = $this->service->listForUser(3);
$this->assertCount(1, $result);
$this->assertSame(t('New user: %s', 'Alice'), $result[0]['title']);
$this->assertSame(['user_id' => 10], $result[0]['data']);
}
}

View File

@@ -122,8 +122,8 @@ export function initNotificationBell(root = document, config = {}) {
`<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>` +
(n.is_read === 0 ? `<button type="button" class="app-notification-item__action-btn" data-action="mark-read" title="${escapeHtml(dropdown.dataset.labelMarkRead || '')}" aria-label="${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 || '')}" aria-label="${escapeHtml(dropdown.dataset.labelDismiss || '')}"><i class="bi bi-x"></i></button>` +
`</div>`;
// Keyboard + click navigation for items with a link