notificationLogger()->log($tenant, $attributes); } protected function createNotificationReceipt( Tenant $tenant, TenantNotificationLog $log, ?int $userId, ?string $recipient ): TenantNotificationReceipt { return TenantNotificationReceipt::query()->create([ 'tenant_id' => $tenant->id, 'notification_log_id' => $log->id, 'user_id' => $userId, 'recipient' => $recipient, 'status' => 'delivered', ]); } protected function dispatchToRecipients( Tenant $tenant, iterable $recipients, string $type, callable $callback, array $context = [] ): void { foreach ($recipients as $recipient) { try { $callback($recipient); $log = $this->notificationLogger()->log($tenant, [ 'type' => $type, 'channel' => 'mail', 'recipient' => $recipient, 'status' => 'sent', 'context' => $context, 'sent_at' => now(), ]); $this->createNotificationReceipt( $tenant, $log, $tenant->user?->id, $recipient ); } catch (\Throwable $e) { Log::error('Tenant notification failed', [ 'tenant_id' => $tenant->id, 'type' => $type, 'recipient' => $recipient, 'error' => $e->getMessage(), ]); $this->logNotification($tenant, [ 'type' => $type, 'channel' => 'mail', 'recipient' => $recipient, 'status' => 'failed', 'context' => $context, 'failed_at' => now(), 'failure_reason' => $e->getMessage(), ]); } } } /** * Simple idempotency guard to avoid duplicate notifications within a cooldown window. * * @param string[] $dedupeKeys */ protected function isDuplicateNotification( Tenant $tenant, string $type, array $context, array $dedupeKeys, int $cooldownMinutes = 1440 ): bool { $window = Carbon::now()->subMinutes($cooldownMinutes); $logs = TenantNotificationLog::query() ->where('tenant_id', $tenant->id) ->where('type', $type) ->whereIn('status', ['sent', 'queued']) ->where(function ($query) use ($window) { $query->whereNull('created_at') ->orWhere('created_at', '>=', $window) ->orWhere('sent_at', '>=', $window); }) ->get(); foreach ($logs as $log) { $existing = is_array($log->context) ? $log->context : []; $matches = true; foreach ($dedupeKeys as $key) { $currentValue = $context[$key] ?? null; $existingValue = $existing[$key] ?? null; if ($currentValue != $existingValue) { $matches = false; break; } } if ($matches) { return true; } } return false; } }