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