channels = [ 'email' => new EmailChannel(), 'telegram' => new TelegramChannel(), 'discord' => new DiscordChannel(), 'slack' => new SlackChannel(), 'mattermost' => new MattermostChannel(), 'webhook' => new WebhookChannel(), 'pushover' => new PushoverChannel(), ]; } /** * Send notification to specified channel */ public function send(string $channelType, array $config, string $message, array $data = []): bool { if (!isset($this->channels[$channelType])) { return false; } try { return $this->channels[$channelType]->send($config, $message, $data); } catch (\Exception $e) { $logger = new \App\Services\Logger(); $logger->error("Notification send failed", [ 'channel_type' => $channelType, 'error' => $e->getMessage() ]); return false; } } /** * Send notification to all active channels in a group */ public function sendToGroup(int $groupId, string $subject, string $message, array $data = []): array { // Get active channels for the group $channelModel = new \App\Models\NotificationChannel(); $channels = $channelModel->getByGroupId($groupId); $results = []; foreach ($channels as $channel) { if (!$channel['is_active']) { continue; // Skip inactive channels } $config = json_decode($channel['channel_config'], true); // Add subject to data for channels that support it (like email) $channelData = array_merge(['subject' => $subject], $data); $success = $this->send( $channel['channel_type'], $config, $message, $channelData ); $results[] = [ 'channel' => $channel['channel_type'], 'success' => $success ]; } return $results; } /** * Send domain expiration notification */ public function sendDomainExpirationAlert(array $domain, array $notificationChannels): array { $daysLeft = $this->calculateDaysLeft($domain['expiration_date']); $message = $this->formatExpirationMessage($domain, $daysLeft); $results = []; foreach ($notificationChannels as $channel) { $config = json_decode($channel['channel_config'], true); $success = $this->send( $channel['channel_type'], $config, $message, [ 'domain' => $domain['domain_name'], 'domain_id' => $domain['id'], 'days_left' => $daysLeft, 'expiration_date' => $domain['expiration_date'], 'registrar' => $domain['registrar'] ] ); $results[] = [ 'channel' => $channel['channel_type'], 'success' => $success ]; } return $results; } /** * Send domain status change notification via external channels */ public function sendDomainStatusAlert(array $domain, array $notificationChannels, string $newStatus, string $oldStatus): array { $message = $this->formatStatusChangeMessage($domain, $newStatus, $oldStatus); $results = []; foreach ($notificationChannels as $channel) { $config = json_decode($channel['channel_config'], true); $success = $this->send( $channel['channel_type'], $config, $message, [ 'domain' => $domain['domain_name'], 'domain_id' => $domain['id'], 'new_status' => $newStatus, 'old_status' => $oldStatus, 'registrar' => $domain['registrar'] ?? 'Unknown' ] ); $results[] = [ 'channel' => $channel['channel_type'], 'success' => $success ]; } return $results; } /** * Format status change notification message */ private function formatStatusChangeMessage(array $domain, string $newStatus, string $oldStatus): string { $domainName = $domain['domain_name']; $registrar = $domain['registrar'] ?? 'Unknown'; $oldStatusLabel = self::getStatusLabel($oldStatus); $newStatusLabel = self::getStatusLabel($newStatus); return match($newStatus) { 'available' => "đŸŸĸ AVAILABLE: Domain '$domainName' is now available for registration!\n\n" . "Previous status: $oldStatusLabel\n" . "This domain can now be registered.", 'active' => "✅ REGISTERED: Domain '$domainName' is now registered and active.\n\n" . "Previous status: $oldStatusLabel\n" . "Registrar: $registrar", 'expired' => "🚨 EXPIRED: Domain '$domainName' has expired!\n\n" . "Previous status: $oldStatusLabel\n" . "Registrar: $registrar\n" . "Please renew immediately to avoid losing your domain.", 'redemption_period' => "âš ī¸ REDEMPTION PERIOD: Domain '$domainName' has entered the redemption period!\n\n" . "Previous status: $oldStatusLabel\n" . "Registrar: $registrar\n" . "The domain can still be recovered, but additional fees may apply. Act quickly!", 'pending_delete' => "🔴 PENDING DELETE: Domain '$domainName' is scheduled for deletion!\n\n" . "Previous status: $oldStatusLabel\n" . "Registrar: $registrar\n" . "The domain will be released for public registration soon.", default => "â„šī¸ STATUS CHANGE: Domain '$domainName' status changed from $oldStatusLabel to $newStatusLabel.\n\n" . "Registrar: $registrar" }; } /** * Get human-readable status label */ public static function getStatusLabel(string $status): string { return match($status) { 'active' => 'Active', 'expiring_soon' => 'Expiring Soon', 'expired' => 'Expired', 'available' => 'Available', 'redemption_period' => 'Redemption Period', 'pending_delete' => 'Pending Delete', 'error' => 'Error', default => ucfirst(str_replace('_', ' ', $status)) }; } /** * Format expiration message */ private function formatExpirationMessage(array $domain, int $daysLeft): string { $domainName = $domain['domain_name']; $expirationDate = $domain['expiration_date'] ? date('F j, Y', strtotime($domain['expiration_date'])) : 'Unknown'; $registrar = $domain['registrar'] ?? 'Unknown'; if ($daysLeft <= 0) { return "🚨 URGENT: Domain '$domainName' has EXPIRED on $expirationDate!\n\n" . "Registrar: $registrar\n" . "Please renew immediately to avoid losing your domain."; } if ($daysLeft == 1) { return "âš ī¸ CRITICAL: Domain '$domainName' expires TOMORROW ($expirationDate)!\n\n" . "Registrar: $registrar\n" . "Please renew as soon as possible."; } if ($daysLeft <= 7) { return "âš ī¸ WARNING: Domain '$domainName' expires in $daysLeft days ($expirationDate)!\n\n" . "Registrar: $registrar\n" . "Please renew soon."; } return "â„šī¸ REMINDER: Domain '$domainName' expires in $daysLeft days ($expirationDate).\n\n" . "Registrar: $registrar\n" . "Please plan for renewal."; } /** * Calculate days left until expiration */ private function calculateDaysLeft(string $expirationDate): int { $expiration = strtotime($expirationDate); $now = time(); return (int)floor(($expiration - $now) / 86400); } // ======================================== // IN-APP NOTIFICATION METHODS (Bell Icon) // ======================================== /** * Create a domain expiring notification (in-app) */ public function notifyDomainExpiring(int $userId, string $domainName, int $daysLeft, int $domainId): void { $notificationModel = new \App\Models\Notification(); $notificationModel->createNotification( $userId, 'domain_expiring', 'Domain Expiring Soon', "{$domainName} expires in {$daysLeft} day" . ($daysLeft > 1 ? 's' : ''), $domainId ); } /** * Create a domain expired notification (in-app) */ public function notifyDomainExpired(int $userId, string $domainName, int $domainId): void { $notificationModel = new \App\Models\Notification(); $notificationModel->createNotification( $userId, 'domain_expired', 'Domain Expired', "{$domainName} has expired - renew immediately", $domainId ); } /** * Create a domain available notification (in-app) */ public function notifyDomainAvailable(int $userId, string $domainName, int $domainId): void { $notificationModel = new \App\Models\Notification(); $notificationModel->createNotification( $userId, 'domain_available', 'Domain Available', "{$domainName} is now available for registration", $domainId ); } /** * Create a domain registered notification (in-app) * Triggered when a domain transitions from available/expired/pending_delete to active */ public function notifyDomainRegistered(int $userId, string $domainName, int $domainId): void { $notificationModel = new \App\Models\Notification(); $notificationModel->createNotification( $userId, 'domain_registered', 'Domain Registered', "{$domainName} has been registered and is now active", $domainId ); } /** * Create a domain redemption period notification (in-app) */ public function notifyDomainRedemption(int $userId, string $domainName, int $domainId): void { $notificationModel = new \App\Models\Notification(); $notificationModel->createNotification( $userId, 'domain_redemption', 'Domain in Redemption Period', "{$domainName} has entered the redemption period - recovery fees may apply", $domainId ); } /** * Create a domain pending delete notification (in-app) */ public function notifyDomainPendingDelete(int $userId, string $domainName, int $domainId): void { $notificationModel = new \App\Models\Notification(); $notificationModel->createNotification( $userId, 'domain_pending_delete', 'Domain Pending Deletion', "{$domainName} is scheduled for deletion and will be available soon", $domainId ); } /** * Create a domain WHOIS updated notification (in-app) */ public function notifyDomainUpdated(int $userId, string $domainName, int $domainId, string $changes = ''): void { $notificationModel = new \App\Models\Notification(); $message = !empty($changes) ? "{$domainName} - {$changes}" : "{$domainName} WHOIS data updated"; $notificationModel->createNotification( $userId, 'domain_updated', 'Domain WHOIS Updated', $message, $domainId ); } /** * Create a WHOIS lookup failed notification (in-app) */ public function notifyWhoisFailed(int $userId, string $domainName, int $domainId, string $reason = ''): void { $notificationModel = new \App\Models\Notification(); $message = !empty($reason) ? "Could not refresh {$domainName} - {$reason}" : "Could not refresh {$domainName}"; $notificationModel->createNotification( $userId, 'whois_failed', 'WHOIS Lookup Failed', $message, $domainId ); } /** * Create a new login notification (in-app) with rich geolocation data */ public function notifyNewLogin(int $userId, string $method, string $ipAddress, ?string $userAgent = null): void { // Get geolocation data $geo = \App\Models\SessionManager::getGeolocationData($ipAddress); // Parse browser/device from user agent $browser = 'Unknown Browser'; $device = 'Desktop'; $deviceIcon = 'desktop'; if ($userAgent) { $ua = strtolower($userAgent); // Browser detection if (strpos($ua, 'edg') !== false) { $browser = 'Edge'; } elseif (strpos($ua, 'opr') !== false || strpos($ua, 'opera') !== false) { $browser = 'Opera'; } elseif (strpos($ua, 'chrome') !== false) { $browser = 'Chrome'; } elseif (strpos($ua, 'safari') !== false) { $browser = 'Safari'; } elseif (strpos($ua, 'firefox') !== false) { $browser = 'Firefox'; } // Device detection if (strpos($ua, 'mobile') !== false || strpos($ua, 'android') !== false || strpos($ua, 'iphone') !== false) { $device = 'Mobile'; $deviceIcon = 'mobile-alt'; } elseif (strpos($ua, 'tablet') !== false || strpos($ua, 'ipad') !== false) { $device = 'Tablet'; $deviceIcon = 'tablet-alt'; } // OS detection $os = 'Unknown'; if (strpos($ua, 'windows') !== false) $os = 'Windows'; elseif (strpos($ua, 'macintosh') !== false || strpos($ua, 'mac os') !== false) $os = 'macOS'; elseif (strpos($ua, 'linux') !== false) $os = 'Linux'; elseif (strpos($ua, 'android') !== false) $os = 'Android'; elseif (strpos($ua, 'iphone') !== false || strpos($ua, 'ipad') !== false) $os = 'iOS'; } // Build location string $locationParts = []; if ($geo['city'] !== 'Unknown' && $geo['city'] !== 'Local') { $locationParts[] = $geo['city']; } if ($geo['country'] !== 'Unknown' && $geo['country'] !== 'Local') { $locationParts[] = $geo['country']; } $locationStr = !empty($locationParts) ? implode(', ', $locationParts) : 'Unknown location'; // Store rich data as JSON in message field $messageData = json_encode([ 'method' => $method, 'ip' => $ipAddress, 'country' => $geo['country'], 'country_code' => $geo['country_code'], 'city' => $geo['city'], 'region' => $geo['region'], 'isp' => $geo['isp'], 'browser' => $browser, 'device' => $device, 'device_icon' => $deviceIcon, 'os' => $os ?? 'Unknown', 'location' => $locationStr, ]); $notificationModel = new \App\Models\Notification(); $notificationModel->createNotification( $userId, 'session_new', 'New Login Detected', $messageData, null ); } /** * Create a failed login notification (in-app) with rich geolocation data */ public function notifyFailedLogin(int $userId, string $reason, string $ipAddress, ?string $userAgent = null, ?string $attemptedUsername = null): void { // Get geolocation data $geo = \App\Models\SessionManager::getGeolocationData($ipAddress); // Parse browser/device from user agent $browser = 'Unknown Browser'; $device = 'Desktop'; $deviceIcon = 'desktop'; if ($userAgent) { $ua = strtolower($userAgent); // Browser detection if (strpos($ua, 'edg') !== false) { $browser = 'Edge'; } elseif (strpos($ua, 'opr') !== false || strpos($ua, 'opera') !== false) { $browser = 'Opera'; } elseif (strpos($ua, 'chrome') !== false) { $browser = 'Chrome'; } elseif (strpos($ua, 'safari') !== false) { $browser = 'Safari'; } elseif (strpos($ua, 'firefox') !== false) { $browser = 'Firefox'; } // Device detection if (strpos($ua, 'mobile') !== false || strpos($ua, 'android') !== false || strpos($ua, 'iphone') !== false) { $device = 'Mobile'; $deviceIcon = 'mobile-alt'; } elseif (strpos($ua, 'tablet') !== false || strpos($ua, 'ipad') !== false) { $device = 'Tablet'; $deviceIcon = 'tablet-alt'; } // OS detection $os = 'Unknown'; if (strpos($ua, 'windows') !== false) $os = 'Windows'; elseif (strpos($ua, 'macintosh') !== false || strpos($ua, 'mac os') !== false) $os = 'macOS'; elseif (strpos($ua, 'linux') !== false) $os = 'Linux'; elseif (strpos($ua, 'android') !== false) $os = 'Android'; elseif (strpos($ua, 'iphone') !== false || strpos($ua, 'ipad') !== false) $os = 'iOS'; } // Build location string $locationParts = []; if ($geo['city'] !== 'Unknown' && $geo['city'] !== 'Local') { $locationParts[] = $geo['city']; } if ($geo['country'] !== 'Unknown' && $geo['country'] !== 'Local') { $locationParts[] = $geo['country']; } $locationStr = !empty($locationParts) ? implode(', ', $locationParts) : 'Unknown location'; // Store rich data as JSON in message field $messageData = json_encode([ 'reason' => $reason, 'attempted_username' => $attemptedUsername, 'ip' => $ipAddress, 'country' => $geo['country'], 'country_code' => $geo['country_code'], 'city' => $geo['city'], 'region' => $geo['region'], 'isp' => $geo['isp'], 'browser' => $browser, 'device' => $device, 'device_icon' => $deviceIcon, 'os' => $os ?? 'Unknown', 'location' => $locationStr, ]); $notificationModel = new \App\Models\Notification(); $notificationModel->createNotification( $userId, 'session_failed', 'Failed Login Attempt', $messageData, null ); } // Future improvement: Add notifyAdminsFailedLogin() to send in-app alerts to all admins on failed login attempts (e.g. unknown usernames, brute-force detection) /** * Create welcome notification for new users/fresh install (in-app) */ public function notifyWelcome(int $userId, string $username): void { $notificationModel = new \App\Models\Notification(); $notificationModel->createNotification( $userId, 'system_welcome', 'Welcome to Domain Monitor! 🎉', "Hi {$username}! Your account is ready. Start by adding your first domain to monitor.", null ); } /** * Create system upgrade notification for admins (in-app) * @param bool $composerManualRequired If true, appends a note to run composer install manually (e.g. when exec is disabled on cPanel) */ public function notifySystemUpgrade(int $userId, string $fromVersion, string $toVersion, int $migrationsCount, bool $composerManualRequired = false): void { $migrationLabel = $migrationsCount . ' migration' . ($migrationsCount !== 1 ? 's' : '') . ' applied'; if ($fromVersion === $toVersion) { // Hotfix: same version, just file updates $message = "Domain Monitor v{$toVersion} has been updated ({$migrationLabel})"; } else { $message = "Domain Monitor upgraded from v{$fromVersion} to v{$toVersion} ({$migrationLabel})"; } if ($composerManualRequired) { $message .= ". Composer could not be run here (e.g. exec disabled). If dependencies changed, run \"composer install --no-dev\" manually via SSH or Terminal."; } $notificationModel = new \App\Models\Notification(); $notificationModel->createNotification( $userId, 'system_upgrade', 'System Upgraded Successfully', $message, null ); } /** * Notify all admins about system upgrade (in-app) * @param bool $composerManualRequired If true, in-app message will include a note to run composer install manually */ public function notifyAdminsUpgrade(string $fromVersion, string $toVersion, int $migrationsCount, bool $composerManualRequired = false): void { try { $userModel = new \App\Models\User(); $admins = $userModel->getAllAdmins(); foreach ($admins as $admin) { $this->notifySystemUpgrade($admin['id'], $fromVersion, $toVersion, $migrationsCount, $composerManualRequired); } } catch (\Exception $e) { $logger = new \App\Services\Logger(); $logger->error("Failed to notify admins about upgrade", [ 'error' => $e->getMessage() ]); } } /** * Create "update available" in-app notification for one user */ public function notifyUpdateAvailable(int $userId, string $currentVersion, string $latestVersion, string $type = 'release', ?int $commitsBehind = null): void { $notificationModel = new \App\Models\Notification(); $title = 'Update Available'; if ($type === 'release') { $message = "A new version of Domain Monitor is available: v{$latestVersion} (you have v{$currentVersion}). Go to Settings → Updates to apply."; } else { $msg = $commitsBehind ? "{$commitsBehind} new commit(s) are available on the main branch. Go to Settings → Updates to apply the hotfix." : "New commits are available. Go to Settings → Updates to apply the hotfix."; $message = $msg; } $notificationModel->createNotification( $userId, 'update_available', $title, $message, null ); } /** * Notify all admins that an update is available (in-app) * Used by cron when it detects a new version or hotfix. */ public function notifyAdminsUpdateAvailable(string $currentVersion, string $latestVersionOrLabel, string $type = 'release', ?int $commitsBehind = null): void { try { $userModel = new \App\Models\User(); $admins = $userModel->getAllAdmins(); foreach ($admins as $admin) { $this->notifyUpdateAvailable($admin['id'], $currentVersion, $latestVersionOrLabel, $type, $commitsBehind); } } catch (\Exception $e) { $logger = new \App\Services\Logger(); $logger->error("Failed to notify admins about available update", [ 'error' => $e->getMessage() ]); } } /** * Delete old read notifications (cleanup) */ public function cleanOldNotifications(int $daysOld = 30): void { try { $pdo = \Core\Database::getConnection(); $stmt = $pdo->prepare( "DELETE FROM user_notifications WHERE is_read = 1 AND read_at < DATE_SUB(NOW(), INTERVAL ? DAY)" ); $stmt->execute([$daysOld]); } catch (\Exception $e) { $logger = new \App\Services\Logger(); $logger->error("Failed to clean old notifications", [ 'error' => $e->getMessage() ]); } } }