Add error log management and bulk admin actions
Introduces error log tracking with new ErrorLog model, controller, views, and migration. Adds admin UI for viewing, resolving, and deleting errors. Implements bulk actions for users and notification groups, refactors domain filtering/pagination, and centralizes admin access checks using Auth::requireAdmin().
This commit is contained in:
@@ -139,5 +139,76 @@ class Domain extends Model
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get filtered, sorted, and paginated domains
|
||||
*/
|
||||
public function getFilteredPaginated(array $filters, string $sortBy, string $sortOrder, int $page, int $perPage, int $expiringThreshold = 30): array
|
||||
{
|
||||
// Get all domains with groups
|
||||
$domains = $this->getAllWithGroups();
|
||||
|
||||
// Apply search filter
|
||||
if (!empty($filters['search'])) {
|
||||
$domains = array_filter($domains, function($domain) use ($filters) {
|
||||
return stripos($domain['domain_name'], $filters['search']) !== false ||
|
||||
stripos($domain['registrar'] ?? '', $filters['search']) !== false;
|
||||
});
|
||||
}
|
||||
|
||||
// Apply status filter
|
||||
if (!empty($filters['status'])) {
|
||||
$domains = array_filter($domains, function($domain) use ($filters, $expiringThreshold) {
|
||||
if ($filters['status'] === 'expiring_soon') {
|
||||
// Check if domain expires within configured threshold
|
||||
if (!empty($domain['expiration_date'])) {
|
||||
$daysLeft = floor((strtotime($domain['expiration_date']) - time()) / 86400);
|
||||
return $daysLeft <= $expiringThreshold && $daysLeft >= 0;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return $domain['status'] === $filters['status'];
|
||||
});
|
||||
}
|
||||
|
||||
// Apply group filter
|
||||
if (!empty($filters['group'])) {
|
||||
$domains = array_filter($domains, function($domain) use ($filters) {
|
||||
return $domain['notification_group_id'] == $filters['group'];
|
||||
});
|
||||
}
|
||||
|
||||
// Get total count after filtering
|
||||
$totalDomains = count($domains);
|
||||
|
||||
// Apply sorting
|
||||
usort($domains, function($a, $b) use ($sortBy, $sortOrder) {
|
||||
$aVal = $a[$sortBy] ?? '';
|
||||
$bVal = $b[$sortBy] ?? '';
|
||||
|
||||
$comparison = strcasecmp($aVal, $bVal);
|
||||
return $sortOrder === 'desc' ? -$comparison : $comparison;
|
||||
});
|
||||
|
||||
// Calculate pagination
|
||||
$totalPages = ceil($totalDomains / $perPage);
|
||||
$page = min($page, max(1, $totalPages)); // Ensure page is within valid range
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
// Slice array for current page
|
||||
$paginatedDomains = array_slice($domains, $offset, $perPage);
|
||||
|
||||
return [
|
||||
'domains' => $paginatedDomains,
|
||||
'pagination' => [
|
||||
'current_page' => $page,
|
||||
'per_page' => $perPage,
|
||||
'total' => $totalDomains,
|
||||
'total_pages' => $totalPages,
|
||||
'showing_from' => $totalDomains > 0 ? $offset + 1 : 0,
|
||||
'showing_to' => min($offset + $perPage, $totalDomains)
|
||||
]
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
400
app/Models/ErrorLog.php
Normal file
400
app/Models/ErrorLog.php
Normal file
@@ -0,0 +1,400 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Core\Model;
|
||||
|
||||
/**
|
||||
* ErrorLog Model
|
||||
*
|
||||
* Manages error log database operations for tracking and debugging
|
||||
*/
|
||||
class ErrorLog extends Model
|
||||
{
|
||||
protected static string $table = 'error_logs';
|
||||
|
||||
/**
|
||||
* Log an error to database
|
||||
* If the same error exists (same file + line + type), increment occurrence count
|
||||
*/
|
||||
public function logError(array $errorData): ?int
|
||||
{
|
||||
// Generate unique error signature for deduplication
|
||||
$signature = md5($errorData['error_type'] . $errorData['error_file'] . $errorData['error_line']);
|
||||
|
||||
// Check if this error already exists
|
||||
$existing = $this->findBySimilar(
|
||||
$errorData['error_type'],
|
||||
$errorData['error_file'],
|
||||
$errorData['error_line']
|
||||
);
|
||||
|
||||
if ($existing) {
|
||||
// Update existing error
|
||||
$this->incrementOccurrence($existing['id']);
|
||||
return $existing['id'];
|
||||
}
|
||||
|
||||
// Create new error log
|
||||
return $this->create($errorData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find similar error (same type, file, line)
|
||||
*/
|
||||
private function findBySimilar(string $type, string $file, int $line): ?array
|
||||
{
|
||||
$sql = "SELECT * FROM error_logs
|
||||
WHERE error_type = ?
|
||||
AND error_file = ?
|
||||
AND error_line = ?
|
||||
AND is_resolved = FALSE
|
||||
LIMIT 1";
|
||||
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$stmt->execute([$type, $file, $line]);
|
||||
$result = $stmt->fetch();
|
||||
|
||||
return $result ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment occurrence counter
|
||||
*/
|
||||
private function incrementOccurrence(int $id): void
|
||||
{
|
||||
$sql = "UPDATE error_logs
|
||||
SET occurrences = occurrences + 1,
|
||||
last_occurred_at = NOW()
|
||||
WHERE id = ?";
|
||||
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$stmt->execute([$id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find error by error_id (unique reference)
|
||||
*/
|
||||
public function findByErrorId(string $errorId): ?array
|
||||
{
|
||||
$sql = "SELECT el.*, u.username, u.full_name, r.username as resolved_by_name
|
||||
FROM error_logs el
|
||||
LEFT JOIN users u ON el.user_id = u.id
|
||||
LEFT JOIN users r ON el.resolved_by = r.id
|
||||
WHERE el.error_id = ?";
|
||||
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$stmt->execute([$errorId]);
|
||||
$result = $stmt->fetch();
|
||||
|
||||
return $result ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent errors with pagination
|
||||
*/
|
||||
public function getRecent(int $limit = 50, int $offset = 0, array $filters = []): array
|
||||
{
|
||||
$where = ['1=1'];
|
||||
$params = [];
|
||||
|
||||
// Filter by resolution status
|
||||
if (isset($filters['resolved'])) {
|
||||
$where[] = 'el.is_resolved = ?';
|
||||
$params[] = $filters['resolved'] ? 1 : 0;
|
||||
}
|
||||
|
||||
// Filter by error type
|
||||
if (!empty($filters['type'])) {
|
||||
$where[] = 'el.error_type LIKE ?';
|
||||
$params[] = '%' . $filters['type'] . '%';
|
||||
}
|
||||
|
||||
// Filter by user
|
||||
if (!empty($filters['user_id'])) {
|
||||
$where[] = 'el.user_id = ?';
|
||||
$params[] = $filters['user_id'];
|
||||
}
|
||||
|
||||
$whereClause = implode(' AND ', $where);
|
||||
|
||||
$sql = "SELECT el.*, u.username, u.full_name
|
||||
FROM error_logs el
|
||||
LEFT JOIN users u ON el.user_id = u.id
|
||||
WHERE {$whereClause}
|
||||
ORDER BY el.last_occurred_at DESC
|
||||
LIMIT ? OFFSET ?";
|
||||
|
||||
$params[] = $limit;
|
||||
$params[] = $offset;
|
||||
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Count total errors
|
||||
*/
|
||||
public function count(array $filters = []): int
|
||||
{
|
||||
$where = ['1=1'];
|
||||
$params = [];
|
||||
|
||||
if (isset($filters['resolved'])) {
|
||||
$where[] = 'is_resolved = ?';
|
||||
$params[] = $filters['resolved'] ? 1 : 0;
|
||||
}
|
||||
|
||||
if (!empty($filters['type'])) {
|
||||
$where[] = 'error_type LIKE ?';
|
||||
$params[] = '%' . $filters['type'] . '%';
|
||||
}
|
||||
|
||||
if (!empty($filters['user_id'])) {
|
||||
$where[] = 'user_id = ?';
|
||||
$params[] = $filters['user_id'];
|
||||
}
|
||||
|
||||
$whereClause = implode(' AND ', $where);
|
||||
|
||||
$sql = "SELECT COUNT(*) as count FROM error_logs WHERE {$whereClause}";
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
return (int)$stmt->fetch()['count'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get error statistics
|
||||
*/
|
||||
public function getStats(): array
|
||||
{
|
||||
$sql = "SELECT
|
||||
COUNT(*) as total_errors,
|
||||
SUM(occurrences) as total_occurrences,
|
||||
COUNT(CASE WHEN is_resolved = FALSE THEN 1 END) as unresolved,
|
||||
COUNT(CASE WHEN is_resolved = TRUE THEN 1 END) as resolved,
|
||||
COUNT(CASE WHEN occurred_at >= DATE_SUB(NOW(), INTERVAL 24 HOUR) THEN 1 END) as last_24h,
|
||||
COUNT(CASE WHEN occurred_at >= DATE_SUB(NOW(), INTERVAL 7 DAY) THEN 1 END) as last_7d
|
||||
FROM error_logs";
|
||||
|
||||
$stmt = $this->db->query($sql);
|
||||
return $stmt->fetch();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark error as resolved
|
||||
*/
|
||||
public function resolve(int $id, int $resolvedBy, ?string $notes = null): bool
|
||||
{
|
||||
return $this->update($id, [
|
||||
'is_resolved' => true,
|
||||
'resolved_at' => date('Y-m-d H:i:s'),
|
||||
'resolved_by' => $resolvedBy,
|
||||
'notes' => $notes
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete old resolved errors
|
||||
*/
|
||||
public function deleteOldResolved(int $daysOld = 30): int
|
||||
{
|
||||
$sql = "DELETE FROM error_logs
|
||||
WHERE is_resolved = TRUE
|
||||
AND resolved_at < DATE_SUB(NOW(), INTERVAL ? DAY)";
|
||||
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$stmt->execute([$daysOld]);
|
||||
return $stmt->rowCount();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get most frequent errors
|
||||
*/
|
||||
public function getMostFrequent(int $limit = 10): array
|
||||
{
|
||||
$sql = "SELECT el.*, u.username, u.full_name
|
||||
FROM error_logs el
|
||||
LEFT JOIN users u ON el.user_id = u.id
|
||||
WHERE el.is_resolved = FALSE
|
||||
ORDER BY el.occurrences DESC, el.last_occurred_at DESC
|
||||
LIMIT ?";
|
||||
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$stmt->execute([$limit]);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get paginated errors with filters for admin panel
|
||||
*/
|
||||
public function getPaginatedErrors(array $filters, int $perPage, int $offset): array
|
||||
{
|
||||
$where = [];
|
||||
$params = [];
|
||||
|
||||
if ($filters['resolved'] !== '') {
|
||||
$where[] = 'is_resolved = ?';
|
||||
$params[] = (int)$filters['resolved'];
|
||||
}
|
||||
|
||||
if (!empty($filters['type'])) {
|
||||
$where[] = 'error_type LIKE ?';
|
||||
$params[] = '%' . $filters['type'] . '%';
|
||||
}
|
||||
|
||||
$whereClause = !empty($where) ? 'WHERE ' . implode(' AND ', $where) : '';
|
||||
$sortColumn = $filters['sort'];
|
||||
$sortOrder = strtoupper($filters['order']) === 'DESC' ? 'DESC' : 'ASC';
|
||||
|
||||
$query = "
|
||||
SELECT
|
||||
error_id,
|
||||
error_type,
|
||||
error_message,
|
||||
error_file,
|
||||
error_line,
|
||||
is_resolved,
|
||||
MIN(occurred_at) as occurred_at,
|
||||
MAX(occurred_at) as last_occurred_at,
|
||||
COUNT(*) as occurrences
|
||||
FROM error_logs
|
||||
$whereClause
|
||||
GROUP BY error_id
|
||||
ORDER BY $sortColumn $sortOrder
|
||||
LIMIT ? OFFSET ?
|
||||
";
|
||||
|
||||
$stmt = $this->db->prepare($query);
|
||||
$stmt->execute([...$params, $perPage, $offset]);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Count total unique errors with filters
|
||||
*/
|
||||
public function countUniqueErrors(array $filters): int
|
||||
{
|
||||
$where = [];
|
||||
$params = [];
|
||||
|
||||
if ($filters['resolved'] !== '') {
|
||||
$where[] = 'is_resolved = ?';
|
||||
$params[] = (int)$filters['resolved'];
|
||||
}
|
||||
|
||||
if (!empty($filters['type'])) {
|
||||
$where[] = 'error_type LIKE ?';
|
||||
$params[] = '%' . $filters['type'] . '%';
|
||||
}
|
||||
|
||||
$whereClause = !empty($where) ? 'WHERE ' . implode(' AND ', $where) : '';
|
||||
|
||||
$query = "SELECT COUNT(DISTINCT error_id) as total FROM error_logs $whereClause";
|
||||
$stmt = $this->db->prepare($query);
|
||||
$stmt->execute($params);
|
||||
return (int)$stmt->fetch()['total'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all occurrences of a specific error
|
||||
*/
|
||||
public function getOccurrencesByErrorId(string $errorId): array
|
||||
{
|
||||
$stmt = $this->db->prepare("
|
||||
SELECT * FROM error_logs
|
||||
WHERE error_id = ?
|
||||
ORDER BY occurred_at DESC
|
||||
");
|
||||
$stmt->execute([$errorId]);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get admin statistics
|
||||
*/
|
||||
public function getAdminStats(): array
|
||||
{
|
||||
// Total unique errors
|
||||
$stmt = $this->db->query("SELECT COUNT(DISTINCT error_id) as total FROM error_logs");
|
||||
$totalErrors = $stmt->fetch()['total'];
|
||||
|
||||
// Unresolved errors
|
||||
$stmt = $this->db->query("SELECT COUNT(DISTINCT error_id) as total FROM error_logs WHERE is_resolved = 0");
|
||||
$unresolved = $stmt->fetch()['total'];
|
||||
|
||||
// Errors in last 24h
|
||||
$stmt = $this->db->query("SELECT COUNT(DISTINCT error_id) as total FROM error_logs WHERE occurred_at >= DATE_SUB(NOW(), INTERVAL 24 HOUR)");
|
||||
$last24h = $stmt->fetch()['total'];
|
||||
|
||||
// Total occurrences
|
||||
$stmt = $this->db->query("SELECT COUNT(*) as total FROM error_logs");
|
||||
$totalOccurrences = $stmt->fetch()['total'];
|
||||
|
||||
return [
|
||||
'total_errors' => $totalErrors,
|
||||
'unresolved' => $unresolved,
|
||||
'last_24h' => $last24h,
|
||||
'total_occurrences' => $totalOccurrences
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark all occurrences of an error as resolved
|
||||
*/
|
||||
public function markErrorResolved(string $errorId, int $userId, ?string $notes): bool
|
||||
{
|
||||
$stmt = $this->db->prepare("
|
||||
UPDATE error_logs
|
||||
SET is_resolved = 1,
|
||||
resolved_at = NOW(),
|
||||
resolved_by = ?,
|
||||
resolution_notes = ?
|
||||
WHERE error_id = ?
|
||||
");
|
||||
|
||||
return $stmt->execute([$userId, $notes, $errorId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark all occurrences of an error as unresolved
|
||||
*/
|
||||
public function markErrorUnresolved(string $errorId): bool
|
||||
{
|
||||
$stmt = $this->db->prepare("
|
||||
UPDATE error_logs
|
||||
SET is_resolved = 0,
|
||||
resolved_at = NULL,
|
||||
resolved_by = NULL,
|
||||
resolution_notes = NULL
|
||||
WHERE error_id = ?
|
||||
");
|
||||
|
||||
return $stmt->execute([$errorId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all occurrences of an error
|
||||
*/
|
||||
public function deleteByErrorId(string $errorId): bool
|
||||
{
|
||||
$stmt = $this->db->prepare("DELETE FROM error_logs WHERE error_id = ?");
|
||||
return $stmt->execute([$errorId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear old resolved errors
|
||||
*/
|
||||
public function clearOldResolved(int $daysOld): int
|
||||
{
|
||||
$stmt = $this->db->prepare("
|
||||
DELETE FROM error_logs
|
||||
WHERE is_resolved = 1
|
||||
AND resolved_at < DATE_SUB(NOW(), INTERVAL ? DAY)
|
||||
");
|
||||
$stmt->execute([$daysOld]);
|
||||
return $stmt->rowCount();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,5 +144,115 @@ class User extends Model
|
||||
$stmt->execute($params);
|
||||
return (int)$stmt->fetch(\PDO::FETCH_ASSOC)['total'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update email verification token
|
||||
*/
|
||||
public function updateEmailVerificationToken(int $userId, string $token): bool
|
||||
{
|
||||
$stmt = $this->db->prepare(
|
||||
"UPDATE users SET email_verification_token = ?, email_verification_sent_at = NOW() WHERE id = ?"
|
||||
);
|
||||
return $stmt->execute([$token, $userId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark email as verified
|
||||
*/
|
||||
public function markEmailAsVerified(int $userId): bool
|
||||
{
|
||||
$stmt = $this->db->prepare("UPDATE users SET email_verified = 1 WHERE id = ?");
|
||||
return $stmt->execute([$userId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify email by clearing token
|
||||
*/
|
||||
public function verifyEmailByToken(int $userId): bool
|
||||
{
|
||||
$stmt = $this->db->prepare(
|
||||
"UPDATE users SET email_verified = 1, email_verification_token = NULL WHERE id = ?"
|
||||
);
|
||||
return $stmt->execute([$userId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find user by email verification token
|
||||
*/
|
||||
public function findByVerificationToken(string $token): ?array
|
||||
{
|
||||
$stmt = $this->db->prepare(
|
||||
"SELECT * FROM users WHERE email_verification_token = ? AND email_verified = 0"
|
||||
);
|
||||
$stmt->execute([$token]);
|
||||
$result = $stmt->fetch();
|
||||
return $result ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create password reset token
|
||||
*/
|
||||
public function createPasswordResetToken(int $userId, string $token, string $expiresAt): bool
|
||||
{
|
||||
$stmt = $this->db->prepare(
|
||||
"INSERT INTO password_reset_tokens (user_id, token, expires_at) VALUES (?, ?, ?)"
|
||||
);
|
||||
return $stmt->execute([$userId, $token, $expiresAt]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find valid password reset token
|
||||
*/
|
||||
public function findPasswordResetToken(string $token): ?array
|
||||
{
|
||||
$stmt = $this->db->prepare(
|
||||
"SELECT * FROM password_reset_tokens WHERE token = ? AND used = 0 AND expires_at > NOW()"
|
||||
);
|
||||
$stmt->execute([$token]);
|
||||
$result = $stmt->fetch();
|
||||
return $result ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark password reset token as used
|
||||
*/
|
||||
public function markPasswordResetTokenAsUsed(int $tokenId): bool
|
||||
{
|
||||
$stmt = $this->db->prepare("UPDATE password_reset_tokens SET used = 1 WHERE id = ?");
|
||||
return $stmt->execute([$tokenId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create remember token
|
||||
*/
|
||||
public function createRememberToken(int $userId, string $sessionId, string $token, string $expiresAt): bool
|
||||
{
|
||||
$stmt = $this->db->prepare(
|
||||
"INSERT INTO remember_tokens (user_id, session_id, token, expires_at) VALUES (?, ?, ?, ?)"
|
||||
);
|
||||
return $stmt->execute([$userId, $sessionId, $token, $expiresAt]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find user by remember token
|
||||
*/
|
||||
public function findByRememberToken(string $token): ?array
|
||||
{
|
||||
$stmt = $this->db->prepare(
|
||||
"SELECT user_id FROM remember_tokens WHERE token = ? AND expires_at > NOW()"
|
||||
);
|
||||
$stmt->execute([$token]);
|
||||
$result = $stmt->fetch();
|
||||
return $result ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete remember token
|
||||
*/
|
||||
public function deleteRememberToken(string $token): bool
|
||||
{
|
||||
$stmt = $this->db->prepare("DELETE FROM remember_tokens WHERE token = ?");
|
||||
return $stmt->execute([$token]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user