true, 'user.deleted' => true, 'user.activated' => true, 'user.deactivated' => true, 'user.assignment_changed' => true, ]; public function __construct( private readonly NotificationRepositoryInterface $notificationRepository, private readonly UserTenantRepositoryInterface $userTenantRepository ) { } /** @return list> */ public function listForUser(int $userId, ?int $tenantId = null, int $limit = 50): array { if ($userId <= 0) { return []; } return $this->notificationRepository->listByUser($userId, $tenantId, $limit, 0); } 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 { 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, ]); } /** * 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->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; } /** * 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 { if ($tenantId <= 0 || $type === '' || $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->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); } /** * @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 []; } $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 $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 ''; } }