Type → dedup window in minutes (true = default window). */ private const DEDUPE_TYPES = [ 'user.created' => true, 'user.deleted' => true, 'user.activated' => true, 'user.deactivated' => true, 'user.assignment_changed' => true, 'scheduler.job_failed' => 1440, ]; public function __construct( private readonly NotificationRepository $notificationRepository, private readonly UserTenantRepositoryInterface $userTenantRepository ) { } /** @return list> */ public function listForUser(int $userId, ?int $tenantId = null, int $limit = 50): array { if ($userId <= 0) { return []; } $notifications = $this->notificationRepository->listByUser($userId, $tenantId, $limit, 0); return $this->localizeNotifications($notifications); } public function unreadCount(int $userId, ?int $tenantId = null): int { if ($userId <= 0) { return 0; } return $this->notificationRepository->countUnreadByUser($userId, $tenantId); } public function markAsRead(int $userId, int $notificationId, ?int $tenantId = null): bool { if ($userId <= 0 || $notificationId <= 0) { return false; } return $this->notificationRepository->markRead($notificationId, $userId, $tenantId); } public function markAllAsRead(int $userId, ?int $tenantId = null): int { if ($userId <= 0) { return 0; } return $this->notificationRepository->markAllReadByUser($userId, $tenantId); } public function dismiss(int $userId, int $notificationId, ?int $tenantId = null): bool { if ($userId <= 0 || $notificationId <= 0) { return false; } return $this->notificationRepository->delete($notificationId, $userId, $tenantId); } public function createForUser( int $recipientUserId, ?int $tenantId, string $type, string $title, ?string $body, ?string $link, array $data ): int|false { $message = NotificationMessage::plain($type, $title, $body, $link, $data); return $this->createFromMessage($recipientUserId, $tenantId, $message); } /** * 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 { $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 $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()) === '') { 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 $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; } $userIds = $this->userTenantRepository->listActiveUserIdsByTenantId($tenantId); $created = 0; foreach ($userIds as $userId) { if (!isset($allowedUserIds[$userId])) { continue; } if ($excludeUserId !== null && $userId === $excludeUserId) { continue; } $result = $this->createFromMessage($userId, $tenantId, $message); 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); } /** * @param array $data * @return array{fingerprint: string, bucket: int}|array{} */ private function buildDedupeMeta(int $recipientUserId, ?int $tenantId, string $type, array $data): array { if (!isset(self::DEDUPE_TYPES[$type])) { return []; } $target = $this->dedupeTargetFromData($data); if ($target === '') { return []; } $windowMinutes = self::DEDUPE_TYPES[$type]; $windowMinutes = is_int($windowMinutes) && $windowMinutes > 0 ? $windowMinutes : self::DEDUPE_WINDOW_MINUTES; $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(), $windowMinutes * 60), ]; } /** * @param array $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; } return ''; } /** * @param list> $notifications * @return list> */ private function localizeNotifications(array $notifications): array { $localized = []; foreach ($notifications as $notification) { $localized[] = $this->localizeNotification($notification); } return $localized; } /** * @param array $notification * @return array */ 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 $data * @return array{title_key: string, title_params: list, body_key: string, body_params: list}|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 $params */ private function translateWithParams(string $key, array $params): string { return (string) t($key, ...$params); } /** * @return list */ 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 */ 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 */ 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; } }