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.
This commit is contained in:
Hosteroid
2026-02-08 22:58:59 +02:00
parent f32de0a848
commit e334f7c9d6
24 changed files with 1597 additions and 200 deletions

View File

@@ -102,9 +102,14 @@ $stats = [
'in_app_notifications_created' => 0,
'domains_with_notifications' => 0,
'notification_groups_used' => [],
'domains_notified' => []
'domains_notified' => [],
'status_changes' => 0,
'status_notifications_sent' => 0
];
// Get notification status triggers from settings
$statusTriggers = $settingModel->getNotificationStatusTriggers();
// Retry queue: domains that failed due to rate limiting
$retryQueue = [];
@@ -180,34 +185,194 @@ foreach ($domains as $domain) {
$stats['checked']++;
$stats['updated']++;
// Detect status change
$oldStatus = $domain['status'];
logMessage(" ✓ Updated WHOIS data for $domainName");
logMessage(" Expiration: " . ($whoisData['expiration_date'] ?? 'N/A') . ", Status: $status");
logMessage(" Expiration: " . ($whoisData['expiration_date'] ?? 'N/A') . ", Status: $status" . ($oldStatus !== $status ? " (was: $oldStatus)" : ""));
// Add a small delay between domain checks to avoid rate limiting
// This helps especially with .nl and other TLDs that have strict rate limits
usleep(1000000); // 1 second delay between checks
// Check if notifications should be sent
// ============================================================
// STATUS CHANGE NOTIFICATIONS
// ============================================================
// Check if the domain status has changed and if the new status
// is in the configured notification triggers
$statusChanged = ($oldStatus !== $status);
$statusNotificationType = null;
if ($statusChanged) {
$stats['status_changes']++;
logMessage(" 🔄 Status changed: $oldStatus$status");
// Determine the notification trigger type for this status change
// 'registered' trigger fires when status changes TO 'active' FROM certain statuses
if ($status === 'available' && in_array('available', $statusTriggers)) {
$statusNotificationType = 'domain_available';
} elseif ($status === 'active' && in_array($oldStatus, ['available', 'expired', 'pending_delete', 'redemption_period', 'error']) && in_array('registered', $statusTriggers)) {
$statusNotificationType = 'domain_registered';
} elseif ($status === 'expired' && $oldStatus !== 'error' && in_array('expired', $statusTriggers)) {
$statusNotificationType = 'domain_expired_status';
} elseif ($status === 'redemption_period' && in_array('redemption_period', $statusTriggers)) {
$statusNotificationType = 'domain_redemption';
} elseif ($status === 'pending_delete' && in_array('pending_delete', $statusTriggers)) {
$statusNotificationType = 'domain_pending_delete';
}
}
// Send status change notifications (both external and in-app)
if ($statusNotificationType) {
logMessage(" 📢 Status notification triggered: $statusNotificationType");
$domainData = $domainModel->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']}");