From e334f7c9d608a23c5a3e93759bbbc573bd9cb7fc Mon Sep 17 00:00:00 2001 From: Hosteroid Date: Sun, 8 Feb 2026 22:58:59 +0200 Subject: [PATCH] Add domain status notifications & login alerts Introduce richer notifications and domain status handling across the app. - NotificationService: Add domain status alert formatting/sending, in-app notifications for available/registered/redemption/pending_delete, richer session_new and session_failed notifications (geolocation + UA parsing) and helpers for human-readable status labels. - Auth/TwoFactor: Emit notifications for successful logins (including remember-me and 2FA) and failed login attempts; update last-login timestamp on various flows. - DomainController: Wrap bulk domain create in try/catch to handle duplicate race conditions and log failures. - WhoisService: Detect redemption_period and pending_delete statuses from WHOIS/EPP statuses. - Settings/Setting: Add settings support for notification status triggers and bump default app_version to 1.1.2; persist/update status trigger values. - Views/Layout/View helpers: Add parsing/formatting for login notification data, add new status labels/classes (available, redemption_period, pending_delete), update notification icons/colors mapping. - Top-nav & Notifications UI: Enhance dropdown with rich login/failed-login display (flags, device icons), clickable domain redirects when marking read, badge IDs for dynamic updates. - Error admin UI: Add copy error report button with robust clipboard fallback and toast UI reused from messages; improved copy UX in admin index/detail. - Installer: Add new migration 024 to installer migration lists and adjust detected toVersion to 1.1.2. - DB: Add migration file 024_add_status_notifications_v1.1.2.sql (new file). These changes add user-facing alerts for domain lifecycle events and stronger login/security notifications while improving UI feedback and robustness during bulk operations. --- app/Controllers/AuthController.php | 47 ++- app/Controllers/DomainController.php | 53 ++- app/Controllers/InstallerController.php | 9 +- app/Controllers/NotificationController.php | 37 +- app/Controllers/SettingsController.php | 11 + app/Controllers/TwoFactorController.php | 21 ++ app/Helpers/DomainHelper.php | 10 + app/Helpers/LayoutHelper.php | 52 ++- app/Helpers/ViewHelper.php | 6 + app/Models/Setting.php | 23 +- app/Services/NotificationService.php | 310 +++++++++++++++- app/Services/WhoisService.php | 23 ++ app/Views/domains/index.php | 3 + app/Views/errors/admin-detail.php | 170 ++++++++- app/Views/errors/admin-index.php | 41 ++- app/Views/layout/top-nav.php | 159 +++++++- app/Views/notifications/index.php | 167 ++++++++- app/Views/settings/index.php | 87 +++++ app/Views/tags/view.php | 3 + app/Views/users/edit.php | 346 ++++++++++++------ app/Views/users/index.php | 13 +- cron/check_domains.php | 181 ++++++++- .../migrations/000_initial_schema_v1.1.0.sql | 5 +- .../024_add_status_notifications_v1.1.2.sql | 20 + 24 files changed, 1597 insertions(+), 200 deletions(-) create mode 100644 database/migrations/024_add_status_notifications_v1.1.2.sql diff --git a/app/Controllers/AuthController.php b/app/Controllers/AuthController.php index 5306b33..566c453 100644 --- a/app/Controllers/AuthController.php +++ b/app/Controllers/AuthController.php @@ -94,10 +94,10 @@ class AuthController extends Controller } if (!$user) { - $logger = new \App\Services\Logger(); - $logger->warning("Login failed - User not found or not active", [ + $this->logger->warning("Login failed - User not found or not active", [ 'username' => $username, - 'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown' + 'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown', + 'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? 'unknown' ]); $_SESSION['error'] = 'Invalid username or password'; $this->redirect('/login'); @@ -106,11 +106,23 @@ class AuthController extends Controller // Verify password if (!$this->userModel->verifyPassword($password, $user['password'])) { - $logger = new \App\Services\Logger(); - $logger->warning("Login failed - Password verification failed", [ + $this->logger->warning("Login failed - Wrong password", [ 'username' => $username, - 'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown' + 'user_id' => $user['id'], + 'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown', + 'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? 'unknown' ]); + + // Notify the target user about failed login attempt (wrong password) + try { + $ipAddress = $_SERVER['REMOTE_ADDR'] ?? 'unknown'; + $userAgent = $_SERVER['HTTP_USER_AGENT'] ?? null; + $notificationService = new \App\Services\NotificationService(); + $notificationService->notifyFailedLogin($user['id'], 'Wrong password', $ipAddress, $userAgent, $username); + } catch (\Exception $e) { + // Don't block response if notification fails + } + $_SESSION['error'] = 'Invalid username or password'; $this->redirect('/login'); return; @@ -183,6 +195,16 @@ class AuthController extends Controller // Update last login $this->userModel->updateLastLogin($user['id']); + // Create login notification + try { + $ipAddress = $_SERVER['REMOTE_ADDR'] ?? 'unknown'; + $userAgent = $_SERVER['HTTP_USER_AGENT'] ?? null; + $notificationService = new \App\Services\NotificationService(); + $notificationService->notifyNewLogin($user['id'], 'Direct login', $ipAddress, $userAgent); + } catch (\Exception $e) { + // Don't block login if notification fails + } + // Set success message for login $_SESSION['success'] = 'Login successful! Welcome back, ' . htmlspecialchars($user['full_name']) . '.'; @@ -744,6 +766,19 @@ class AuthController extends Controller // Session is automatically tracked by DatabaseSessionHandler // No need to manually create session record + // Update last login timestamp + $this->userModel->updateLastLogin($user['id']); + + // Create login notification for remember-me auto-login + try { + $ipAddress = $_SERVER['REMOTE_ADDR'] ?? 'unknown'; + $userAgent = $_SERVER['HTTP_USER_AGENT'] ?? null; + $notificationService = new \App\Services\NotificationService(); + $notificationService->notifyNewLogin($user['id'], 'Remember me', $ipAddress, $userAgent); + } catch (\Exception $e) { + // Don't block login if notification fails + } + return true; } } diff --git a/app/Controllers/DomainController.php b/app/Controllers/DomainController.php index 8c8ccbe..23a8d5d 100644 --- a/app/Controllers/DomainController.php +++ b/app/Controllers/DomainController.php @@ -680,28 +680,41 @@ class DomainController extends Controller $availableCount++; } - $domainId = $this->domainModel->create([ - 'domain_name' => $domainName, - 'notification_group_id' => $groupId, - 'registrar' => $whoisData['registrar'], - 'registrar_url' => $whoisData['registrar_url'] ?? null, - 'expiration_date' => $whoisData['expiration_date'], - 'updated_date' => $whoisData['updated_date'] ?? null, - 'abuse_email' => $whoisData['abuse_email'] ?? null, - 'last_checked' => date('Y-m-d H:i:s'), - 'status' => $status, - 'whois_data' => json_encode($whoisData), - 'is_active' => 1, - 'user_id' => \Core\Auth::id() - ]); + try { + $domainId = $this->domainModel->create([ + 'domain_name' => $domainName, + 'notification_group_id' => $groupId, + 'registrar' => $whoisData['registrar'], + 'registrar_url' => $whoisData['registrar_url'] ?? null, + 'expiration_date' => $whoisData['expiration_date'], + 'updated_date' => $whoisData['updated_date'] ?? null, + 'abuse_email' => $whoisData['abuse_email'] ?? null, + 'last_checked' => date('Y-m-d H:i:s'), + 'status' => $status, + 'whois_data' => json_encode($whoisData), + 'is_active' => 1, + 'user_id' => \Core\Auth::id() + ]); - // Handle tags using the new tag system - if (!empty($tags) && $domainId) { - $tagModel = new \App\Models\Tag(); - $tagModel->updateDomainTags($domainId, $tags, $userId); + // Handle tags using the new tag system + if (!empty($tags) && $domainId) { + $tagModel = new \App\Models\Tag(); + $tagModel->updateDomainTags($domainId, $tags, $userId); + } + + $added++; + } catch (\PDOException $e) { + // Handle duplicate key (race condition between existsByDomain check and insert) + if (str_contains($e->getMessage(), 'Duplicate entry')) { + $skipped++; + } else { + $logger->error('Failed to add domain in bulk', [ + 'domain' => $domainName, + 'error' => $e->getMessage() + ]); + $errors[] = $domainName; + } } - - $added++; } // Log bulk add completion diff --git a/app/Controllers/InstallerController.php b/app/Controllers/InstallerController.php index dd87d9d..953c07f 100644 --- a/app/Controllers/InstallerController.php +++ b/app/Controllers/InstallerController.php @@ -54,6 +54,7 @@ class InstallerController extends Controller '021_add_avatar_field.sql', '022_add_pushover_channel_type.sql', '023_update_app_version_to_1.1.1.sql', + '024_add_status_notifications_v1.1.2.sql', ]; try { @@ -194,6 +195,7 @@ class InstallerController extends Controller '021_add_avatar_field.sql', '022_add_pushover_channel_type.sql', '023_update_app_version_to_1.1.1.sql', + '024_add_status_notifications_v1.1.2.sql', ]; } @@ -379,6 +381,7 @@ class InstallerController extends Controller '021_add_avatar_field.sql', '022_add_pushover_channel_type.sql', '023_update_app_version_to_1.1.1.sql', + '024_add_status_notifications_v1.1.2.sql', ]; $stmt = $pdo->prepare("INSERT INTO migrations (migration) VALUES (?) ON DUPLICATE KEY UPDATE migration=migration"); @@ -597,10 +600,12 @@ class InstallerController extends Controller // Determine from/to versions based on migrations $fromVersion = '1.0.0'; - $toVersion = '1.1.1'; + $toVersion = '1.1.2'; // Detect version based on which migrations were run - if (in_array('022_add_pushover_channel_type.sql', $executed)) { + if (in_array('024_add_status_notifications_v1.1.2.sql', $executed)) { + $toVersion = '1.1.2'; + } elseif (in_array('022_add_pushover_channel_type.sql', $executed)) { $toVersion = '1.1.1'; } elseif (in_array('011_create_sessions_table.sql', $executed) || in_array('012_link_remember_tokens_to_sessions.sql', $executed) || diff --git a/app/Controllers/NotificationController.php b/app/Controllers/NotificationController.php index b7113ac..c2b9828 100644 --- a/app/Controllers/NotificationController.php +++ b/app/Controllers/NotificationController.php @@ -57,6 +57,7 @@ class NotificationController extends Controller $notification['time_ago'] = $this->timeAgo($notification['created_at']); $notification['icon'] = $this->getNotificationIcon($notification['type']); $notification['color'] = $this->getNotificationColor($notification['type']); + $notification['login_data'] = \App\Helpers\LayoutHelper::parseLoginData($notification); } $this->view('notifications/index', [ @@ -77,6 +78,7 @@ class NotificationController extends Controller /** * Mark notification as read + * Supports optional redirect to domain if ?redirect=domain */ public function markAsRead($params = []) { @@ -90,6 +92,27 @@ class NotificationController extends Controller } $this->notificationModel->markAsRead($notificationId, $userId); + + // If redirect=domain, go to the domain view page + $redirect = $_GET['redirect'] ?? ''; + if ($redirect === 'domain') { + $domainId = (int)($_GET['domain_id'] ?? 0); + if ($domainId > 0) { + $this->redirect('/domains/' . $domainId); + return; + } + } + + // AJAX request - return JSON (check multiple detection methods) + $isAjax = (!empty($_SERVER['HTTP_X_REQUESTED_WITH']) && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest') + || (isset($_SERVER['HTTP_ACCEPT']) && strpos($_SERVER['HTTP_ACCEPT'], 'application/json') !== false) + || !empty($_GET['ajax']); + if ($isAjax) { + $unreadCount = $this->notificationModel->getUnreadCount($userId); + $this->json(['success' => true, 'id' => $notificationId, 'unread_count' => $unreadCount]); + return; + } + $_SESSION['success'] = 'Notification marked as read'; $this->redirect('/notifications'); } @@ -191,9 +214,14 @@ class NotificationController extends Controller { return match($type) { 'domain_expiring' => 'exclamation-triangle', - 'domain_expired' => 'times-circle', + 'domain_expired', 'domain_expired_status' => 'times-circle', + 'domain_available' => 'check-circle', + 'domain_registered' => 'globe', + 'domain_redemption' => 'hourglass-half', + 'domain_pending_delete' => 'trash-alt', 'domain_updated' => 'sync-alt', 'session_new' => 'sign-in-alt', + 'session_failed' => 'shield-alt', 'whois_failed' => 'exclamation-circle', 'system_welcome' => 'hand-sparkles', 'system_upgrade' => 'arrow-up', @@ -208,9 +236,14 @@ class NotificationController extends Controller { return match($type) { 'domain_expiring' => 'orange', - 'domain_expired' => 'red', + 'domain_expired', 'domain_expired_status' => 'red', + 'domain_available' => 'blue', + 'domain_registered' => 'green', + 'domain_redemption' => 'amber', + 'domain_pending_delete' => 'rose', 'domain_updated' => 'green', 'session_new' => 'blue', + 'session_failed' => 'red', 'whois_failed' => 'gray', 'system_welcome' => 'purple', 'system_upgrade' => 'indigo', diff --git a/app/Controllers/SettingsController.php b/app/Controllers/SettingsController.php index 3012b9a..a0eafee 100644 --- a/app/Controllers/SettingsController.php +++ b/app/Controllers/SettingsController.php @@ -66,6 +66,9 @@ class SettingsController extends Controller ['label' => 'Weekly (168 hours)', 'value' => 168] ]; + // Status notification triggers + $statusTriggers = $this->settingModel->getNotificationStatusTriggers(); + $this->view('settings/index', [ 'settings' => $settings, 'appSettings' => $appSettings, @@ -75,6 +78,7 @@ class SettingsController extends Controller 'isolationSettings' => $isolationSettings, 'notificationPresets' => $notificationPresets, 'checkIntervalPresets' => $checkIntervalPresets, + 'statusTriggers' => $statusTriggers, 'title' => 'Settings' ]); } @@ -132,9 +136,16 @@ class SettingsController extends Controller return; } + // Update status notification triggers + $statusTriggers = $_POST['notification_status_triggers'] ?? []; + if (!is_array($statusTriggers)) { + $statusTriggers = []; + } + // Save settings $this->settingModel->setValue('notification_days_before', $notificationDays); $this->settingModel->setValue('check_interval_hours', $checkInterval); + $this->settingModel->updateNotificationStatusTriggers($statusTriggers); $_SESSION['success'] = 'Settings updated successfully'; $this->redirect('/settings#monitoring'); diff --git a/app/Controllers/TwoFactorController.php b/app/Controllers/TwoFactorController.php index acabded..c7446fc 100644 --- a/app/Controllers/TwoFactorController.php +++ b/app/Controllers/TwoFactorController.php @@ -295,9 +295,30 @@ class TwoFactorController extends Controller 'method' => $method ]); + // Update last login timestamp + $this->userModel->updateLastLogin($userId); + + // Create login notification + try { + $userAgent = $_SERVER['HTTP_USER_AGENT'] ?? null; + $notificationService = new \App\Services\NotificationService(); + $notificationService->notifyNewLogin($userId, "2FA ($method)", $ipAddress, $userAgent); + } catch (\Exception $e) { + // Don't block login if notification fails + } + $_SESSION['success'] = 'Login successful! Welcome back, ' . htmlspecialchars($user['full_name']) . '.'; $this->redirect('/'); } else { + // Notify user about failed 2FA attempt + try { + $userAgent = $_SERVER['HTTP_USER_AGENT'] ?? null; + $notificationService = new \App\Services\NotificationService(); + $notificationService->notifyFailedLogin($userId, 'Failed 2FA verification', $ipAddress, $userAgent, $user['username']); + } catch (\Exception $e) { + // Don't block response if notification fails + } + $_SESSION['error'] = 'Invalid verification code. Please try again.'; $this->redirect('/2fa/verify'); } diff --git a/app/Helpers/DomainHelper.php b/app/Helpers/DomainHelper.php index bb3b188..a54d5ef 100644 --- a/app/Helpers/DomainHelper.php +++ b/app/Helpers/DomainHelper.php @@ -145,6 +145,16 @@ class DomainHelper 'text' => 'Expired', 'icon' => 'fa-times-circle' ], + 'redemption_period' => [ + 'class' => 'bg-amber-100 text-amber-700 border-amber-200', + 'text' => 'Redemption Period', + 'icon' => 'fa-hourglass-half' + ], + 'pending_delete' => [ + 'class' => 'bg-rose-100 text-rose-700 border-rose-200', + 'text' => 'Pending Delete', + 'icon' => 'fa-trash-alt' + ], 'error' => [ 'class' => 'bg-gray-100 text-gray-700 border-gray-200', 'text' => 'Error', diff --git a/app/Helpers/LayoutHelper.php b/app/Helpers/LayoutHelper.php index 6f77203..29ff410 100644 --- a/app/Helpers/LayoutHelper.php +++ b/app/Helpers/LayoutHelper.php @@ -22,6 +22,7 @@ class LayoutHelper $notif['time_ago'] = self::timeAgo($notif['created_at']); $notif['icon'] = self::getNotificationIcon($notif['type']); $notif['color'] = self::getNotificationColor($notif['type']); + $notif['login_data'] = self::parseLoginData($notif); } return [ @@ -52,6 +53,43 @@ class LayoutHelper } } + /** + * Parse session_new notification message (JSON) + * Returns structured data for rich display, or null if not parseable + */ + public static function parseLoginData(array $notification): ?array + { + if ($notification['type'] !== 'session_new' && $notification['type'] !== 'session_failed') { + return null; + } + + $data = json_decode($notification['message'] ?? '', true); + + if (is_array($data) && isset($data['ip'])) { + return $data; + } + + return null; + } + + /** + * Format session_new notification for dropdown display (compact) + */ + public static function formatLoginDropdown(array $loginData): string + { + $parts = []; + if ($loginData['city'] !== 'Unknown' && $loginData['city'] !== 'Local') { + $parts[] = $loginData['city']; + } + if ($loginData['country'] !== 'Unknown' && $loginData['country'] !== 'Local') { + $parts[] = $loginData['country']; + } + $location = !empty($parts) ? implode(', ', $parts) : $loginData['ip']; + + $browser = $loginData['browser'] ?? 'Unknown'; + return "{$location} · {$browser}"; + } + /** * Convert timestamp to "time ago" format */ @@ -83,9 +121,14 @@ class LayoutHelper { return match($type) { 'domain_expiring' => 'exclamation-triangle', - 'domain_expired' => 'times-circle', + 'domain_expired', 'domain_expired_status' => 'times-circle', + 'domain_available' => 'check-circle', + 'domain_registered' => 'globe', + 'domain_redemption' => 'hourglass-half', + 'domain_pending_delete' => 'trash-alt', 'domain_updated' => 'sync-alt', 'session_new' => 'sign-in-alt', + 'session_failed' => 'shield-alt', 'whois_failed' => 'exclamation-circle', 'system_welcome' => 'hand-sparkles', 'system_upgrade' => 'arrow-up', @@ -100,9 +143,14 @@ class LayoutHelper { return match($type) { 'domain_expiring' => 'orange', - 'domain_expired' => 'red', + 'domain_expired', 'domain_expired_status' => 'red', + 'domain_available' => 'blue', + 'domain_registered' => 'green', + 'domain_redemption' => 'amber', + 'domain_pending_delete' => 'rose', 'domain_updated' => 'green', 'session_new' => 'blue', + 'session_failed' => 'red', 'whois_failed' => 'gray', 'system_welcome' => 'purple', 'system_upgrade' => 'indigo', diff --git a/app/Helpers/ViewHelper.php b/app/Helpers/ViewHelper.php index 868a323..103d5b6 100644 --- a/app/Helpers/ViewHelper.php +++ b/app/Helpers/ViewHelper.php @@ -53,6 +53,9 @@ class ViewHelper 'active' => 'bg-green-100 text-green-800 border-green-200', 'expiring_soon' => 'bg-orange-100 text-orange-800 border-orange-200', 'expired' => 'bg-red-100 text-red-800 border-red-200', + 'available' => 'bg-blue-100 text-blue-800 border-blue-200', + 'redemption_period' => 'bg-amber-100 text-amber-800 border-amber-200', + 'pending_delete' => 'bg-rose-100 text-rose-800 border-rose-200', 'inactive' => 'bg-gray-100 text-gray-800 border-gray-200', ]; @@ -60,6 +63,9 @@ class ViewHelper 'active' => 'Active', 'expiring_soon' => 'Expiring Soon', 'expired' => 'Expired', + 'available' => 'Available', + 'redemption_period' => 'Redemption Period', + 'pending_delete' => 'Pending Delete', 'inactive' => 'Inactive', ]; diff --git a/app/Models/Setting.php b/app/Models/Setting.php index 775e3c3..aa1d327 100644 --- a/app/Models/Setting.php +++ b/app/Models/Setting.php @@ -122,7 +122,7 @@ class Setting extends Model */ public function getAppVersion(): string { - return $this->getValue('app_version', '1.1.1'); + return $this->getValue('app_version', '1.1.2'); } /** @@ -301,6 +301,27 @@ class Setting extends Model return $result; } + /** + * Get notification status triggers as array + * Returns which domain status changes should trigger notifications + */ + public function getNotificationStatusTriggers(): array + { + $value = $this->getValue('notification_status_triggers', 'available,registered,expired,redemption_period,pending_delete'); + return array_map('trim', explode(',', $value)); + } + + /** + * Update notification status triggers + */ + public function updateNotificationStatusTriggers(array $triggers): bool + { + $validTriggers = ['available', 'registered', 'expired', 'redemption_period', 'pending_delete']; + $triggers = array_intersect($triggers, $validTriggers); + $value = implode(',', $triggers); + return $this->setValue('notification_status_triggers', $value); + } + /** * Clear old notification logs */ diff --git a/app/Services/NotificationService.php b/app/Services/NotificationService.php index e312e89..a13c4e7 100644 --- a/app/Services/NotificationService.php +++ b/app/Services/NotificationService.php @@ -119,6 +119,95 @@ class NotificationService 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 */ @@ -195,6 +284,67 @@ class NotificationService ); } + /** + * 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) */ @@ -234,20 +384,174 @@ class NotificationService } /** - * Create a new login notification (in-app) + * Create a new login notification (in-app) with rich geolocation data */ - public function notifyNewLogin(int $userId, string $location, string $ipAddress): void + 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', - "Login from {$location} ({$ipAddress})", + $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) */ diff --git a/app/Services/WhoisService.php b/app/Services/WhoisService.php index 51a5b3a..59aaf90 100644 --- a/app/Services/WhoisService.php +++ b/app/Services/WhoisService.php @@ -1107,6 +1107,29 @@ class WhoisService } } + // Check for pending delete status (EPP: pendingDelete) + // Must check before active/registered indicators since a domain can have both + foreach ($statusArray as $status) { + if (stripos($status, 'pendingDelete') !== false || + stripos($status, 'pending delete') !== false || + stripos($status, 'pending_delete') !== false || + stripos($status, 'PENDING-DELETE') !== false) { + return 'pending_delete'; + } + } + + // Check for redemption period status (EPP: redemptionPeriod) + // Must check before active/registered indicators + foreach ($statusArray as $status) { + if (stripos($status, 'redemptionPeriod') !== false || + stripos($status, 'redemption period') !== false || + stripos($status, 'redemption_period') !== false || + stripos($status, 'REDEMPTION-PERIOD') !== false || + stripos($status, 'pendingRestore') !== false) { + return 'redemption_period'; + } + } + // If domain has "active" status but no expiration date, consider it active // This handles TLDs like .nl that don't provide expiration dates via RDAP foreach ($statusArray as $status) { diff --git a/app/Views/domains/index.php b/app/Views/domains/index.php index bd4a77e..f93e6f5 100644 --- a/app/Views/domains/index.php +++ b/app/Views/domains/index.php @@ -68,7 +68,10 @@ $currentFilters = $filters ?? ['search' => '', 'status' => '', 'group' => '', 's + + + diff --git a/app/Views/errors/admin-detail.php b/app/Views/errors/admin-detail.php index cc38b72..149daca 100644 --- a/app/Views/errors/admin-detail.php +++ b/app/Views/errors/admin-detail.php @@ -9,14 +9,18 @@ $isResolved = (bool)$error['is_resolved']; $errorTypeShort = substr(strrchr($error['error_type'], '\\'), 1) ?: $error['error_type']; ?> - +
- + Back to Error Logs
+
@@ -354,31 +358,157 @@ function copyToClipboard(text) { if (navigator.clipboard && window.isSecureContext) { navigator.clipboard.writeText(text).then(() => { showCopySuccess(); + }).catch(() => { + fallbackCopy(text); }); } else { - const textArea = document.createElement('textarea'); - textArea.value = text; - textArea.style.position = 'fixed'; - textArea.style.left = '-999999px'; - document.body.appendChild(textArea); - textArea.select(); - document.execCommand('copy'); - document.body.removeChild(textArea); - showCopySuccess(); + fallbackCopy(text); } } +function fallbackCopy(text) { + const textArea = document.createElement('textarea'); + textArea.value = text; + textArea.style.position = 'fixed'; + textArea.style.left = '-999999px'; + document.body.appendChild(textArea); + textArea.select(); + try { + document.execCommand('copy'); + showCopySuccess(); + } catch (err) { + console.error('Copy failed:', err); + } + document.body.removeChild(textArea); +} + +function copyErrorReport() { + const errorType = ; + const errorMessage = ; + const errorFile = ; + const errorLine = ; + const errorId = ; + const phpVersion = ; + const memoryUsage = ; + const requestMethod = ; + const requestUri = ; + const userAgent = ; + const ipAddress = ; + const occurredAt = ; + const lastOccurredAt = ; + const occurrences = ; + const isResolved = ; + const requestData = ; + const sessionData = ; + + // Get stack trace from the rendered elements + const traceFrames = document.querySelectorAll('#content-stack-trace .bg-gray-50'); + let stackTrace = 'Not available'; + if (traceFrames.length > 0) { + let traceLines = []; + traceFrames.forEach((frame, i) => { + const fileLine = frame.querySelector('.font-mono.text-xs'); + const funcLine = frame.querySelector('.font-mono.text-sm'); + let line = '#' + i + ' '; + if (fileLine) line += fileLine.textContent.trim().replace(/\s+/g, ' '); + if (funcLine) line += ' ' + funcLine.textContent.trim().replace(/\s+/g, ''); + traceLines.push(line); + }); + stackTrace = traceLines.join('\n'); + } + + // Format request data sections + let requestDataText = 'Not available'; + if (requestData && typeof requestData === 'object' && Object.keys(requestData).length > 0) { + let sections = []; + for (const [key, value] of Object.entries(requestData)) { + sections.push(` [${key.toUpperCase()}]\n ${JSON.stringify(value, null, 2).split('\n').join('\n ')}`); + } + requestDataText = sections.join('\n\n'); + } + + // Format session data + let sessionDataText = 'Not available'; + if (sessionData && typeof sessionData === 'object' && Object.keys(sessionData).length > 0) { + sessionDataText = ' ' + JSON.stringify(sessionData, null, 2).split('\n').join('\n '); + } + + const errorReport = `=== DOMAIN MONITOR ERROR REPORT === + +ERROR INFORMATION: +- Error ID: ${errorId} +- Type: ${errorType} +- Message: ${errorMessage} +- Status: ${isResolved ? 'Resolved' : 'Unresolved'} +- Occurrences: ${occurrences} + +LOCATION: +- File: ${errorFile} +- Line: ${errorLine} + +REQUEST DETAILS: +- Method: ${requestMethod} +- URI: ${requestUri} +- IP Address: ${ipAddress} +- User Agent: ${userAgent} +- First Occurred: ${occurredAt} +- Last Occurred: ${lastOccurredAt} + +REQUEST DATA: +${requestDataText} + +SESSION DATA: +${sessionDataText} + +SYSTEM INFORMATION: +- PHP Version: ${phpVersion} +- Memory Usage: ${memoryUsage} + +STACK TRACE: +${stackTrace} + +=== END OF ERROR REPORT === + +Reference ID: ${errorId} +Please include this report when reporting bugs.`; + + copyToClipboard(errorReport); +} + function showCopySuccess() { - const message = document.createElement('div'); - message.className = 'fixed top-4 right-4 bg-green-500 text-white px-4 py-3 rounded-lg shadow-lg z-50 flex items-center'; - message.innerHTML = 'Copied to clipboard!'; - document.body.appendChild(message); - + // Use the existing toast container from messages.php + let container = document.getElementById('toast-container'); + if (!container) { + container = document.createElement('div'); + container.id = 'toast-container'; + container.className = 'fixed bottom-4 right-4 z-[9999] space-y-3 max-w-sm'; + document.body.appendChild(container); + } + + const toast = document.createElement('div'); + toast.className = 'toast bg-white border-l-4 border-green-500 rounded-lg shadow-lg p-4 flex items-start animate-slide-in'; + toast.innerHTML = ` +
+
+ +
+
+
+

Success

+

Copied to clipboard!

+
+ + `; + container.appendChild(toast); + setTimeout(() => { - message.style.opacity = '0'; - message.style.transition = 'opacity 0.3s'; - setTimeout(() => message.remove(), 300); - }, 2000); + toast.style.transition = 'opacity 0.3s ease-out, transform 0.3s ease-out'; + toast.style.opacity = '0'; + toast.style.transform = 'translateX(100%)'; + setTimeout(() => toast.remove(), 300); + }, 3000); } function markResolved() { diff --git a/app/Views/errors/admin-index.php b/app/Views/errors/admin-index.php index de2129c..2cbbae8 100644 --- a/app/Views/errors/admin-index.php +++ b/app/Views/errors/admin-index.php @@ -432,16 +432,39 @@ function copyToClipboard(text) { } function showCopySuccess() { - const message = document.createElement('div'); - message.className = 'fixed top-4 right-4 bg-green-500 text-white px-4 py-3 rounded-lg shadow-lg z-50 flex items-center'; - message.innerHTML = 'Copied to clipboard!'; - document.body.appendChild(message); - + // Use the existing toast container from messages.php + let container = document.getElementById('toast-container'); + if (!container) { + container = document.createElement('div'); + container.id = 'toast-container'; + container.className = 'fixed bottom-4 right-4 z-[9999] space-y-3 max-w-sm'; + document.body.appendChild(container); + } + + const toast = document.createElement('div'); + toast.className = 'toast bg-white border-l-4 border-green-500 rounded-lg shadow-lg p-4 flex items-start animate-slide-in'; + toast.innerHTML = ` +
+
+ +
+
+
+

Success

+

Copied to clipboard!

+
+ + `; + container.appendChild(toast); + setTimeout(() => { - message.style.opacity = '0'; - message.style.transition = 'opacity 0.3s'; - setTimeout(() => message.remove(), 300); - }, 2000); + toast.style.transition = 'opacity 0.3s ease-out, transform 0.3s ease-out'; + toast.style.opacity = '0'; + toast.style.transform = 'translateX(100%)'; + setTimeout(() => toast.remove(), 300); + }, 3000); } let currentErrorId = null; diff --git a/app/Views/layout/top-nav.php b/app/Views/layout/top-nav.php index 54faca8..f28ea93 100644 --- a/app/Views/layout/top-nav.php +++ b/app/Views/layout/top-nav.php @@ -74,7 +74,7 @@

Notifications

0): ?> - new + new
@@ -83,19 +83,84 @@
-
+ + @@ -194,7 +259,7 @@ Notifications 0): ?> - + @@ -220,3 +285,69 @@
+ + + diff --git a/app/Views/notifications/index.php b/app/Views/notifications/index.php index 1678f4b..30dca87 100644 --- a/app/Views/notifications/index.php +++ b/app/Views/notifications/index.php @@ -56,11 +56,16 @@ $offset = $pagination['showing_from'] - 1; + + + + + @@ -129,34 +134,178 @@ $offset = $pagination['showing_from'] - 1; $bgClass = $notification['is_read'] ? '' : 'bg-blue-50'; $iconBgClass = "bg-{$notification['color']}-100"; $iconTextClass = "text-{$notification['color']}-600"; + $hasDomain = !empty($notification['domain_id']); + $domainUrl = $hasDomain ? '/domains/' . $notification['domain_id'] : null; + $clickUrl = null; + if ($hasDomain && !$notification['is_read']) { + $clickUrl = '/notifications/' . $notification['id'] . '/mark-read?redirect=domain&domain_id=' . $notification['domain_id']; + } elseif ($hasDomain) { + $clickUrl = $domainUrl; + } + $loginData = $notification['login_data'] ?? null; + $isLogin = ($notification['type'] === 'session_new' && $loginData); + $isFailedLogin = ($notification['type'] === 'session_failed' && $loginData); ?>
-
+
-
- -
+ +
+ +
+ +
+ +
+ + + + + +
+ +
+
-

+ + + +

+ - + - +
-

+ + + +
+
+ +
+ + Location: + + + + + + +
+ +
+ + IP: + +
+ +
+ + Browser: + +
+ +
+ + Device: + +
+ +
+ + OS: + +
+ +
+ + ISP: + +
+
+ +
+ + Reason: + +
+
+ + +
+
+ +
+ + Location: + + + + + + +
+ +
+ + IP: + +
+ +
+ + Browser: + +
+ +
+ + Device: + +
+ +
+ + OS: + +
+ +
+ + ISP: + +
+
+ +
+ + Method: + +
+
+ + +

+ + + View domain + + +
-
+
diff --git a/app/Views/settings/index.php b/app/Views/settings/index.php index 10bf282..bd56418 100644 --- a/app/Views/settings/index.php +++ b/app/Views/settings/index.php @@ -372,6 +372,93 @@ foreach ($notificationPresets as $key => $preset) {
+ +
+

+ + Status Change Notifications +

+

Choose which domain status changes should trigger notifications (both in-app and external channels).

+ +
+ + + + + + + + + + + + + + +
+ +
+

+ + Note: These notifications are triggered when a domain's status changes during a WHOIS check. + Redemption Period and Pending Delete detection depends on the TLD registry reporting EPP statuses. + Most gTLDs (.com, .net, .org) support this, but some ccTLDs may not. +

+
+
+ +
+

diff --git a/app/Views/tags/view.php b/app/Views/tags/view.php index c83e0af..52aa899 100644 --- a/app/Views/tags/view.php +++ b/app/Views/tags/view.php @@ -72,7 +72,10 @@ $currentFilters = $filters ?? ['search' => '', 'status' => '', 'registrar' => '' + + + diff --git a/app/Views/users/edit.php b/app/Views/users/edit.php index 77a1606..f3f9cfa 100644 --- a/app/Views/users/edit.php +++ b/app/Views/users/edit.php @@ -6,126 +6,268 @@ $pageIcon = 'fas fa-user-edit'; ob_start(); ?> - - - - +
-
-

User Information

+
+

+ + User Information +

-
+ + - diff --git a/app/Views/users/index.php b/app/Views/users/index.php index 3a3b7d1..458994b 100644 --- a/app/Views/users/index.php +++ b/app/Views/users/index.php @@ -230,7 +230,18 @@ $pagination = $pagination ?? [
-
+
+ + + + 2FA + + + + No 2FA + + +
find($domain['id']); + + // --- External notifications (channels) --- + if ($domain['notification_group_id']) { + if (!$logModel->wasSentRecently($domain['id'], $statusNotificationType, 23)) { + $channels = $channelModel->getActiveByGroupId($domain['notification_group_id']); + + if (!empty($channels)) { + logMessage(" 📤 Sending status change alerts to " . count($channels) . " channel(s)"); + + $results = $notificationService->sendDomainStatusAlert($domainData, $channels, $status, $oldStatus); + + foreach ($results as $result) { + $success = $result['success']; + $channel = $result['channel']; + + if ($success) { + logMessage(" ✓ Sent to $channel"); + $stats['status_notifications_sent']++; + $stats['notifications_sent']++; + } else { + logMessage(" ✗ Failed to send to $channel"); + } + + $logModel->log( + $domain['id'], + $statusNotificationType, + $channel, + "Domain $domainName status changed: $oldStatus → $status", + $success, + $success ? null : "Failed to send notification" + ); + } + } + } else { + logMessage(" → Status notification already sent recently (skipping external alerts)"); + } + } + + // --- In-app notifications (bell icon) --- + $isolationMode = $settingModel->getValue('user_isolation_mode', 'shared'); + $usersToNotify = []; + + if ($isolationMode === 'isolated') { + $notificationUserId = null; + if (!empty($domainData['user_id'])) { + $notificationUserId = $domainData['user_id']; + } elseif (!empty($domain['notification_group_id'])) { + $group = $groupModel->find($domain['notification_group_id']); + if ($group && !empty($group['user_id'])) { + $notificationUserId = $group['user_id']; + } + } + if ($notificationUserId) { + $usersToNotify[] = $notificationUserId; + } + } else { + $allUsers = $userModel->where('is_active', 1); + foreach ($allUsers as $user) { + $usersToNotify[] = $user['id']; + } + } + + if (!empty($usersToNotify)) { + $notifiedCount = 0; + + foreach ($usersToNotify as $userId) { + // Check for duplicate in-app notification + $db = \Core\Database::getConnection(); + $stmt = $db->prepare( + "SELECT COUNT(*) as count FROM user_notifications + WHERE user_id = ? AND domain_id = ? AND type = ? + AND created_at >= DATE_SUB(NOW(), INTERVAL 23 HOUR)" + ); + $stmt->execute([$userId, $domain['id'], $statusNotificationType]); + $result = $stmt->fetch(); + + if ($result && $result['count'] > 0) { + continue; + } + + try { + match($statusNotificationType) { + 'domain_available' => $notificationService->notifyDomainAvailable($userId, $domainName, $domain['id']), + 'domain_registered' => $notificationService->notifyDomainRegistered($userId, $domainName, $domain['id']), + 'domain_expired_status' => $notificationService->notifyDomainExpired($userId, $domainName, $domain['id']), + 'domain_redemption' => $notificationService->notifyDomainRedemption($userId, $domainName, $domain['id']), + 'domain_pending_delete' => $notificationService->notifyDomainPendingDelete($userId, $domainName, $domain['id']), + default => null + }; + $notifiedCount++; + } catch (Exception $e) { + logMessage(" ⚠ Failed to create status notification for user $userId: " . $e->getMessage()); + } + } + + if ($notifiedCount > 0) { + logMessage(" 🔔 Created status change notifications for $notifiedCount user(s)"); + $stats['in_app_notifications_created'] += $notifiedCount; + $stats['domains_with_notifications']++; + + $stats['domains_notified'][] = [ + 'domain' => $domainName, + 'days_left' => null, + 'users_notified' => $notifiedCount, + 'has_group' => !empty($domain['notification_group_id']), + 'group_id' => $domain['notification_group_id'] ?? null, + 'status_change' => "$oldStatus → $status" + ]; + + if (!empty($domain['notification_group_id'])) { + $groupId = $domain['notification_group_id']; + if (!isset($stats['notification_groups_used'][$groupId])) { + $stats['notification_groups_used'][$groupId] = 0; + } + $stats['notification_groups_used'][$groupId]++; + } + } + } + } + + // ============================================================ + // EXPIRATION-BASED NOTIFICATIONS (existing logic) + // ============================================================ + // Check if notifications should be sent based on days until expiration $daysLeft = $whoisService->daysUntilExpiration($whoisData['expiration_date']); if ($daysLeft === null) { continue; } - // Check if this domain should trigger a notification + // Check if this domain should trigger an expiration notification $shouldNotify = false; $notificationType = ''; if ($daysLeft <= 0) { - $shouldNotify = true; - $notificationType = 'expired'; + // Only send expiration notification if we didn't already send a status change expired notification + if ($statusNotificationType !== 'domain_expired_status') { + $shouldNotify = true; + $notificationType = 'expired'; + } } elseif (in_array($daysLeft, $notificationDays)) { $shouldNotify = true; $notificationType = "expiring_in_{$daysLeft}_days"; } if (!$shouldNotify) { - logMessage(" → No notification needed ($daysLeft days left)"); + logMessage(" → No expiration notification needed ($daysLeft days left)"); continue; } @@ -646,6 +811,8 @@ $formattedTime = formatElapsedTime($elapsedTime); logMessage("\n=== Cron job completed ==="); logMessage("Domains checked: {$stats['checked']}"); logMessage("Domains updated: {$stats['updated']}"); +logMessage("Status changes detected: {$stats['status_changes']}"); +logMessage("Status notifications sent: {$stats['status_notifications_sent']}"); logMessage("External notifications sent: {$stats['notifications_sent']}"); logMessage("In-app notifications created: {$stats['in_app_notifications_created']}"); logMessage("Domains with notifications: {$stats['domains_with_notifications']}"); diff --git a/database/migrations/000_initial_schema_v1.1.0.sql b/database/migrations/000_initial_schema_v1.1.0.sql index 9636e73..3bf1c27 100644 --- a/database/migrations/000_initial_schema_v1.1.0.sql +++ b/database/migrations/000_initial_schema_v1.1.0.sql @@ -141,7 +141,7 @@ CREATE TABLE IF NOT EXISTS domains ( updated_date DATE, abuse_email VARCHAR(255), last_checked TIMESTAMP NULL, - status ENUM('active', 'expiring_soon', 'expired', 'error', 'available') DEFAULT 'active', + status ENUM('active', 'expiring_soon', 'expired', 'error', 'available', 'redemption_period', 'pending_delete') DEFAULT 'active', whois_data JSON, notes TEXT, is_active BOOLEAN DEFAULT TRUE, @@ -362,7 +362,7 @@ INSERT INTO settings (setting_key, setting_value, `type`, `description`) VALUES ('app_name', 'Domain Monitor', 'string', 'Application name'), ('app_url', 'http://localhost:8000', 'string', 'Application URL'), ('app_timezone', 'UTC', 'string', 'Application timezone'), -('app_version', '1.1.1', 'string', 'Application version number'), +('app_version', '1.1.2', 'string', 'Application version number'), -- Email settings ('mail_host', 'smtp.mailtrap.io', 'string', 'SMTP server host'), @@ -375,6 +375,7 @@ INSERT INTO settings (setting_key, setting_value, `type`, `description`) VALUES -- Monitoring settings ('notification_days_before', '60,30,21,14,7,5,3,2,1', 'string', 'Notification days before expiration'), +('notification_status_triggers', 'available,registered,expired,redemption_period,pending_delete', 'string', 'Domain status changes that trigger notifications'), ('check_interval_hours', '24', 'string', 'Domain check interval in hours'), ('last_check_run', NULL, 'datetime', 'Last time cron job ran'), diff --git a/database/migrations/024_add_status_notifications_v1.1.2.sql b/database/migrations/024_add_status_notifications_v1.1.2.sql new file mode 100644 index 0000000..19f6c3b --- /dev/null +++ b/database/migrations/024_add_status_notifications_v1.1.2.sql @@ -0,0 +1,20 @@ +-- Migration: Add status-based notifications and new domain lifecycle statuses +-- Version: 1.1.2 +-- This migration adds support for notifications based on domain status changes: +-- available, registered, expired, redemption_period, pending_delete + +-- 1. Expand domain status ENUM to include redemption_period and pending_delete +ALTER TABLE domains MODIFY COLUMN status + ENUM('active', 'expiring_soon', 'expired', 'error', 'available', 'redemption_period', 'pending_delete') + DEFAULT 'active'; + +-- 2. Add setting for notification status triggers (which status changes trigger notifications) +INSERT INTO settings (setting_key, setting_value, created_at, updated_at) +VALUES ('notification_status_triggers', 'available,registered,expired,redemption_period,pending_delete', NOW(), NOW()) +ON DUPLICATE KEY UPDATE setting_key = setting_key; + +-- 3. Update application version to 1.1.2 +UPDATE settings +SET setting_value = '1.1.2' +WHERE setting_key = 'app_version'; +