2026-03-19 22:09:44 +01:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace MintyPHP\Module\Notifications\Service;
|
|
|
|
|
|
|
|
|
|
use MintyPHP\Module\Notifications\Repository\NotificationRepositoryInterface;
|
2026-03-20 00:05:03 +01:00
|
|
|
use MintyPHP\Repository\Tenant\UserTenantRepositoryInterface;
|
2026-03-19 22:09:44 +01:00
|
|
|
|
|
|
|
|
class NotificationService
|
|
|
|
|
{
|
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>
2026-03-25 21:12:49 +01:00
|
|
|
private const MESSAGE_META_KEY = '__message';
|
2026-03-20 00:05:03 +01:00
|
|
|
private const DEDUPE_WINDOW_MINUTES = 30;
|
|
|
|
|
private const DEDUPE_TYPES = [
|
|
|
|
|
'user.created' => true,
|
|
|
|
|
'user.deleted' => true,
|
|
|
|
|
'user.activated' => true,
|
|
|
|
|
'user.deactivated' => true,
|
|
|
|
|
'user.assignment_changed' => true,
|
|
|
|
|
];
|
|
|
|
|
|
2026-03-19 22:09:44 +01:00
|
|
|
public function __construct(
|
2026-03-20 00:05:03 +01:00
|
|
|
private readonly NotificationRepositoryInterface $notificationRepository,
|
|
|
|
|
private readonly UserTenantRepositoryInterface $userTenantRepository
|
2026-03-19 22:09:44 +01:00
|
|
|
) {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** @return list<array<string, mixed>> */
|
2026-03-20 00:05:03 +01:00
|
|
|
public function listForUser(int $userId, ?int $tenantId = null, int $limit = 50): array
|
2026-03-19 22:09:44 +01:00
|
|
|
{
|
|
|
|
|
if ($userId <= 0) {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
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>
2026-03-25 21:12:49 +01:00
|
|
|
$notifications = $this->notificationRepository->listByUser($userId, $tenantId, $limit, 0);
|
|
|
|
|
return $this->localizeNotifications($notifications);
|
2026-03-19 22:09:44 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-20 00:05:03 +01:00
|
|
|
public function unreadCount(int $userId, ?int $tenantId = null): int
|
2026-03-19 22:09:44 +01:00
|
|
|
{
|
|
|
|
|
if ($userId <= 0) {
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-20 00:05:03 +01:00
|
|
|
return $this->notificationRepository->countUnreadByUser($userId, $tenantId);
|
2026-03-19 22:09:44 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-20 00:05:03 +01:00
|
|
|
public function markAsRead(int $userId, int $notificationId, ?int $tenantId = null): bool
|
2026-03-19 22:09:44 +01:00
|
|
|
{
|
|
|
|
|
if ($userId <= 0 || $notificationId <= 0) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-20 00:05:03 +01:00
|
|
|
return $this->notificationRepository->markRead($notificationId, $userId, $tenantId);
|
2026-03-19 22:09:44 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-20 00:05:03 +01:00
|
|
|
public function markAllAsRead(int $userId, ?int $tenantId = null): int
|
2026-03-19 22:09:44 +01:00
|
|
|
{
|
|
|
|
|
if ($userId <= 0) {
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-20 00:05:03 +01:00
|
|
|
return $this->notificationRepository->markAllReadByUser($userId, $tenantId);
|
2026-03-19 22:09:44 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-20 00:05:03 +01:00
|
|
|
public function dismiss(int $userId, int $notificationId, ?int $tenantId = null): bool
|
2026-03-19 22:09:44 +01:00
|
|
|
{
|
|
|
|
|
if ($userId <= 0 || $notificationId <= 0) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-20 00:05:03 +01:00
|
|
|
return $this->notificationRepository->delete($notificationId, $userId, $tenantId);
|
2026-03-19 22:09:44 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function createForUser(
|
|
|
|
|
int $recipientUserId,
|
|
|
|
|
?int $tenantId,
|
|
|
|
|
string $type,
|
|
|
|
|
string $title,
|
|
|
|
|
?string $body,
|
|
|
|
|
?string $link,
|
|
|
|
|
array $data
|
|
|
|
|
): int|false {
|
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>
2026-03-25 21:12:49 +01:00
|
|
|
$message = NotificationMessage::plain($type, $title, $body, $link, $data);
|
|
|
|
|
return $this->createFromMessage($recipientUserId, $tenantId, $message);
|
2026-03-19 22:09:44 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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 {
|
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>
2026-03-25 21:12:49 +01:00
|
|
|
$message = NotificationMessage::plain($type, $title, $body, $link, $data);
|
|
|
|
|
return $this->createForTenantUsersFromMessage($tenantId, $message, $excludeUserId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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 {
|
|
|
|
|
$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()) === '') {
|
2026-03-19 22:09:44 +01:00
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-20 00:05:03 +01:00
|
|
|
$userIds = $this->userTenantRepository->listActiveUserIdsByTenantId($tenantId);
|
2026-03-19 22:09:44 +01:00
|
|
|
$created = 0;
|
|
|
|
|
|
|
|
|
|
foreach ($userIds as $userId) {
|
|
|
|
|
if ($excludeUserId !== null && $userId === $excludeUserId) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
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>
2026-03-25 21:12:49 +01:00
|
|
|
$result = $this->createFromMessage($userId, $tenantId, $message);
|
2026-03-19 22:09:44 +01:00
|
|
|
if ($result !== false) {
|
|
|
|
|
$created++;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $created;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
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>
2026-03-25 21:12:49 +01:00
|
|
|
* Fan-out using a typed message contract for tenant users filtered by allowed user IDs.
|
2026-03-19 22:09:44 +01:00
|
|
|
*
|
|
|
|
|
* @param array<int, mixed> $allowedUserIds Flipped array (user ID as key) for fast lookup
|
|
|
|
|
*/
|
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>
2026-03-25 21:12:49 +01:00
|
|
|
public function createForTenantAdminUsersFromMessage(
|
2026-03-19 22:09:44 +01:00
|
|
|
int $tenantId,
|
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>
2026-03-25 21:12:49 +01:00
|
|
|
NotificationMessage $message,
|
2026-03-19 22:09:44 +01:00
|
|
|
?int $excludeUserId,
|
|
|
|
|
array $allowedUserIds
|
|
|
|
|
): int {
|
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>
2026-03-25 21:12:49 +01:00
|
|
|
if ($tenantId <= 0 || trim($message->type()) === '' || trim($message->title()) === '' || $allowedUserIds === []) {
|
2026-03-19 22:09:44 +01:00
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-20 00:05:03 +01:00
|
|
|
$userIds = $this->userTenantRepository->listActiveUserIdsByTenantId($tenantId);
|
2026-03-19 22:09:44 +01:00
|
|
|
$created = 0;
|
|
|
|
|
|
|
|
|
|
foreach ($userIds as $userId) {
|
|
|
|
|
if (!isset($allowedUserIds[$userId])) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if ($excludeUserId !== null && $userId === $excludeUserId) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
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>
2026-03-25 21:12:49 +01:00
|
|
|
$result = $this->createFromMessage($userId, $tenantId, $message);
|
2026-03-19 22:09:44 +01:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-03-20 00:05:03 +01:00
|
|
|
* @param array<string, mixed> $data
|
|
|
|
|
* @return array{fingerprint: string, bucket: int}|array{}
|
2026-03-19 22:09:44 +01:00
|
|
|
*/
|
2026-03-20 00:05:03 +01:00
|
|
|
private function buildDedupeMeta(int $recipientUserId, ?int $tenantId, string $type, array $data): array
|
2026-03-19 22:09:44 +01:00
|
|
|
{
|
2026-03-20 00:05:03 +01:00
|
|
|
if (!isset(self::DEDUPE_TYPES[$type])) {
|
2026-03-19 22:09:44 +01:00
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-20 00:05:03 +01:00
|
|
|
$target = $this->dedupeTargetFromData($data);
|
|
|
|
|
if ($target === '') {
|
2026-03-19 22:09:44 +01:00
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-20 00:05:03 +01:00
|
|
|
$tenantScope = $tenantId !== null && $tenantId > 0 ? $tenantId : 0;
|
|
|
|
|
$raw = implode('|', [
|
|
|
|
|
'recipient:' . $recipientUserId,
|
|
|
|
|
'tenant:' . $tenantScope,
|
|
|
|
|
'type:' . $type,
|
|
|
|
|
'target:' . $target,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
'fingerprint' => hash('sha256', $raw),
|
|
|
|
|
'bucket' => intdiv(time(), self::DEDUPE_WINDOW_MINUTES * 60),
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param array<string, mixed> $data
|
|
|
|
|
*/
|
|
|
|
|
private function dedupeTargetFromData(array $data): string
|
|
|
|
|
{
|
|
|
|
|
$explicit = trim((string) ($data['dedupe_target'] ?? ''));
|
|
|
|
|
if ($explicit !== '') {
|
|
|
|
|
return $explicit;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$targetUserId = (int) ($data['user_id'] ?? 0);
|
|
|
|
|
if ($targetUserId > 0) {
|
|
|
|
|
return 'user:' . $targetUserId;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$targetUuid = trim((string) ($data['uuid'] ?? ''));
|
|
|
|
|
if ($targetUuid !== '') {
|
|
|
|
|
return 'uuid:' . $targetUuid;
|
2026-03-19 22:09:44 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-20 00:05:03 +01:00
|
|
|
return '';
|
2026-03-19 22:09:44 +01:00
|
|
|
}
|
2026-03-20 00:05:03 +01:00
|
|
|
|
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>
2026-03-25 21:12:49 +01:00
|
|
|
/**
|
|
|
|
|
* @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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 22:09:44 +01:00
|
|
|
}
|