f50b493df2
- Create notification management system with VNotifications class - Build notification API endpoints (get, mark read, delete, preferences) - Implement notification bell UI widget with real-time polling - Add email queue system with digest support (instant/hourly/daily/weekly) - Create notification trigger helpers for common events - Add fine-grained user preference controls - Implement cron script for email processing and cleanup - Document setup and usage in docs/NOTIFICATIONS.md Features: - In-app notifications with dropdown - Email delivery with batching - 6 notification types (comment, like, subscribe, upload, mention, system) - User preference controls per notification type - Lightweight polling (30s interval) - Auto-cleanup of old notifications (90 days) - Production-ready with proper error handling
379 lines
12 KiB
PHP
379 lines
12 KiB
PHP
<?php
|
|
/**
|
|
* Notification Management Class
|
|
* Handles creation, retrieval, and delivery of user notifications
|
|
*/
|
|
|
|
class VNotifications {
|
|
private $db;
|
|
private $logger;
|
|
|
|
public function __construct() {
|
|
global $class_database;
|
|
$this->db = $class_database;
|
|
$this->logger = new VLogger('notifications');
|
|
}
|
|
|
|
/**
|
|
* Create a new notification
|
|
*
|
|
* @param int $usr_id User receiving the notification
|
|
* @param string $type Notification type (comment, like, subscribe, video_upload, mention, system)
|
|
* @param string $title Notification title
|
|
* @param string $message Optional detailed message
|
|
* @param string $link Optional link URL
|
|
* @param int $actor_id User who triggered the notification
|
|
* @param int $related_video_id Related video ID
|
|
* @param int $related_comment_id Related comment ID
|
|
* @return int|false Notification ID or false on failure
|
|
*/
|
|
public function create($usr_id, $type, $title, $message = '', $link = '', $actor_id = null, $related_video_id = null, $related_comment_id = null) {
|
|
// Don't notify users about their own actions
|
|
if ($actor_id && $usr_id == $actor_id) {
|
|
return false;
|
|
}
|
|
|
|
// Insert notification
|
|
$sql = "INSERT INTO db_notifications
|
|
(usr_id, type, title, message, link, actor_id, related_video_id, related_comment_id)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
|
|
|
|
$result = $this->db->execute($sql, [
|
|
$usr_id,
|
|
$type,
|
|
$title,
|
|
$message,
|
|
$link,
|
|
$actor_id,
|
|
$related_video_id,
|
|
$related_comment_id
|
|
]);
|
|
|
|
if (!$result) {
|
|
$this->logger->error("Failed to create notification for user $usr_id");
|
|
return false;
|
|
}
|
|
|
|
$notification_id = $this->db->lastInsertId();
|
|
|
|
// Queue email if user preferences allow
|
|
$this->queueEmail($usr_id, $type, $title, $message, $link);
|
|
|
|
$this->logger->info("Created notification $notification_id for user $usr_id (type: $type)");
|
|
|
|
return $notification_id;
|
|
}
|
|
|
|
/**
|
|
* Get unread notification count for a user
|
|
*/
|
|
public function getUnreadCount($usr_id) {
|
|
$sql = "SELECT COUNT(*) as count FROM db_notifications
|
|
WHERE usr_id = ? AND is_read = 0";
|
|
|
|
$result = $this->db->execute($sql, [$usr_id]);
|
|
|
|
if (!$result) {
|
|
return 0;
|
|
}
|
|
|
|
$row = $this->db->fetch($result);
|
|
return intval($row['count'] ?? 0);
|
|
}
|
|
|
|
/**
|
|
* Get notifications for a user
|
|
*/
|
|
public function getUserNotifications($usr_id, $limit = 20, $offset = 0, $unread_only = false) {
|
|
$where = $unread_only ? "AND is_read = 0" : "";
|
|
|
|
$sql = "SELECT n.*, u.usr_user as actor_username, u.usr_avatar as actor_avatar
|
|
FROM db_notifications n
|
|
LEFT JOIN db_accountuser u ON n.actor_id = u.usr_id
|
|
WHERE n.usr_id = ? $where
|
|
ORDER BY n.created_at DESC
|
|
LIMIT ? OFFSET ?";
|
|
|
|
$result = $this->db->execute($sql, [$usr_id, $limit, $offset]);
|
|
|
|
if (!$result) {
|
|
return [];
|
|
}
|
|
|
|
return $this->db->resultsToArray($result);
|
|
}
|
|
|
|
/**
|
|
* Mark notification(s) as read
|
|
*/
|
|
public function markAsRead($notification_id, $usr_id = null) {
|
|
if (is_array($notification_id)) {
|
|
$ids = implode(',', array_map('intval', $notification_id));
|
|
$sql = "UPDATE db_notifications
|
|
SET is_read = 1, read_at = NOW()
|
|
WHERE notification_id IN ($ids)";
|
|
|
|
if ($usr_id) {
|
|
$sql .= " AND usr_id = " . intval($usr_id);
|
|
}
|
|
} else {
|
|
$sql = "UPDATE db_notifications
|
|
SET is_read = 1, read_at = NOW()
|
|
WHERE notification_id = " . intval($notification_id);
|
|
|
|
if ($usr_id) {
|
|
$sql .= " AND usr_id = " . intval($usr_id);
|
|
}
|
|
}
|
|
|
|
return $this->db->execute($sql);
|
|
}
|
|
|
|
/**
|
|
* Mark all notifications as read for a user
|
|
*/
|
|
public function markAllAsRead($usr_id) {
|
|
$sql = "UPDATE db_notifications
|
|
SET is_read = 1, read_at = NOW()
|
|
WHERE usr_id = ? AND is_read = 0";
|
|
|
|
return $this->db->execute($sql, [$usr_id]);
|
|
}
|
|
|
|
/**
|
|
* Mark notifications as seen (displayed to user, but not necessarily read)
|
|
*/
|
|
public function markAsSeen($usr_id) {
|
|
$sql = "UPDATE db_notifications
|
|
SET is_seen = 1
|
|
WHERE usr_id = ? AND is_seen = 0";
|
|
|
|
return $this->db->execute($sql, [$usr_id]);
|
|
}
|
|
|
|
/**
|
|
* Delete a notification
|
|
*/
|
|
public function delete($notification_id, $usr_id) {
|
|
$sql = "DELETE FROM db_notifications
|
|
WHERE notification_id = ? AND usr_id = ?";
|
|
|
|
return $this->db->execute($sql, [$notification_id, $usr_id]);
|
|
}
|
|
|
|
/**
|
|
* Get user notification preferences
|
|
*/
|
|
public function getPreferences($usr_id) {
|
|
$sql = "SELECT * FROM db_notification_preferences WHERE usr_id = ?";
|
|
|
|
$result = $this->db->execute($sql, [$usr_id]);
|
|
|
|
if (!$result || $this->db->rowCount($result) == 0) {
|
|
// Create default preferences
|
|
$this->createDefaultPreferences($usr_id);
|
|
return $this->getPreferences($usr_id);
|
|
}
|
|
|
|
return $this->db->fetch($result);
|
|
}
|
|
|
|
/**
|
|
* Update user notification preferences
|
|
*/
|
|
public function updatePreferences($usr_id, $preferences) {
|
|
$allowed_fields = [
|
|
'email_comments', 'email_likes', 'email_subscribes', 'email_uploads', 'email_mentions',
|
|
'email_digest', 'push_enabled', 'push_comments', 'push_likes', 'push_subscribes'
|
|
];
|
|
|
|
$sets = [];
|
|
$values = [];
|
|
|
|
foreach ($preferences as $field => $value) {
|
|
if (in_array($field, $allowed_fields)) {
|
|
$sets[] = "$field = ?";
|
|
$values[] = $value;
|
|
}
|
|
}
|
|
|
|
if (empty($sets)) {
|
|
return false;
|
|
}
|
|
|
|
$values[] = $usr_id;
|
|
|
|
$sql = "UPDATE db_notification_preferences
|
|
SET " . implode(', ', $sets) . "
|
|
WHERE usr_id = ?";
|
|
|
|
return $this->db->execute($sql, $values);
|
|
}
|
|
|
|
/**
|
|
* Create default notification preferences for a user
|
|
*/
|
|
private function createDefaultPreferences($usr_id) {
|
|
$sql = "INSERT INTO db_notification_preferences (usr_id) VALUES (?)";
|
|
return $this->db->execute($sql, [$usr_id]);
|
|
}
|
|
|
|
/**
|
|
* Queue email notification
|
|
*/
|
|
private function queueEmail($usr_id, $type, $title, $message, $link) {
|
|
// Get user preferences
|
|
$prefs = $this->getPreferences($usr_id);
|
|
|
|
// Check if email is enabled for this type
|
|
$email_field = "email_" . str_replace('_', 's', $type);
|
|
if (isset($prefs[$email_field]) && !$prefs[$email_field]) {
|
|
return false;
|
|
}
|
|
|
|
// Get user email
|
|
$sql = "SELECT usr_email FROM db_accountuser WHERE usr_id = ?";
|
|
$result = $this->db->execute($sql, [$usr_id]);
|
|
|
|
if (!$result) {
|
|
return false;
|
|
}
|
|
|
|
$user = $this->db->fetch($result);
|
|
if (!$user || empty($user['usr_email'])) {
|
|
return false;
|
|
}
|
|
|
|
// Determine send time based on digest preference
|
|
$scheduled_for = 'NOW()';
|
|
|
|
if ($prefs['email_digest'] === 'hourly') {
|
|
$scheduled_for = "DATE_ADD(NOW(), INTERVAL 1 HOUR)";
|
|
} elseif ($prefs['email_digest'] === 'daily') {
|
|
$scheduled_for = "DATE_ADD(CURDATE(), INTERVAL 1 DAY)"; // Tomorrow at midnight
|
|
} elseif ($prefs['email_digest'] === 'weekly') {
|
|
$scheduled_for = "DATE_ADD(CURDATE(), INTERVAL (7 - WEEKDAY(CURDATE())) DAY)"; // Next Monday
|
|
} elseif ($prefs['email_digest'] === 'never') {
|
|
return false;
|
|
}
|
|
|
|
// Build email body
|
|
$email_body = $this->buildEmailBody($title, $message, $link);
|
|
|
|
// Queue email
|
|
$sql = "INSERT INTO db_email_queue (usr_id, email, subject, body, scheduled_for)
|
|
VALUES (?, ?, ?, ?, $scheduled_for)";
|
|
|
|
return $this->db->execute($sql, [
|
|
$usr_id,
|
|
$user['usr_email'],
|
|
$title,
|
|
$email_body
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Build HTML email body
|
|
*/
|
|
private function buildEmailBody($title, $message, $link) {
|
|
$html = "<html><body style='font-family: Arial, sans-serif; line-height: 1.6;'>";
|
|
$html .= "<h2 style='color: #007bff;'>$title</h2>";
|
|
|
|
if ($message) {
|
|
$html .= "<p>" . nl2br(htmlspecialchars($message)) . "</p>";
|
|
}
|
|
|
|
if ($link) {
|
|
$base_url = $_SERVER['HTTP_HOST'] ?? 'localhost';
|
|
$full_link = "http://$base_url$link";
|
|
$html .= "<p><a href='$full_link' style='color: #007bff; text-decoration: none; font-weight: bold;'>View on EasyStream →</a></p>";
|
|
}
|
|
|
|
$html .= "<hr style='border: none; border-top: 1px solid #ddd; margin: 20px 0;'>";
|
|
$html .= "<p style='font-size: 12px; color: #666;'>You received this email because you have notifications enabled on EasyStream. <a href='http://$base_url/settings#notifications'>Manage your notification preferences</a>.</p>";
|
|
$html .= "</body></html>";
|
|
|
|
return $html;
|
|
}
|
|
|
|
/**
|
|
* Process email queue (called by cron)
|
|
*/
|
|
public function processEmailQueue($limit = 50) {
|
|
$sql = "SELECT * FROM db_email_queue
|
|
WHERE sent_at IS NULL
|
|
AND scheduled_for <= NOW()
|
|
AND attempts < 3
|
|
ORDER BY scheduled_for ASC
|
|
LIMIT $limit";
|
|
|
|
$result = $this->db->execute($sql);
|
|
|
|
if (!$result) {
|
|
return 0;
|
|
}
|
|
|
|
$emails = $this->db->resultsToArray($result);
|
|
$sent_count = 0;
|
|
|
|
foreach ($emails as $email) {
|
|
if ($this->sendEmail($email)) {
|
|
$sent_count++;
|
|
|
|
// Mark as sent
|
|
$this->db->execute(
|
|
"UPDATE db_email_queue SET sent_at = NOW() WHERE queue_id = ?",
|
|
[$email['queue_id']]
|
|
);
|
|
|
|
// Mark notification as emailed
|
|
$this->db->execute(
|
|
"UPDATE db_notifications SET email_sent = 1 WHERE usr_id = ? AND email_sent = 0 AND created_at >= ?",
|
|
[$email['usr_id'], $email['created_at']]
|
|
);
|
|
} else {
|
|
// Increment attempts
|
|
$this->db->execute(
|
|
"UPDATE db_email_queue SET attempts = attempts + 1, last_error = ? WHERE queue_id = ?",
|
|
['Failed to send email', $email['queue_id']]
|
|
);
|
|
}
|
|
}
|
|
|
|
$this->logger->info("Processed email queue: $sent_count sent out of " . count($emails) . " pending");
|
|
|
|
return $sent_count;
|
|
}
|
|
|
|
/**
|
|
* Send an email
|
|
*/
|
|
private function sendEmail($email) {
|
|
// Use PHP mail() function
|
|
// In production, integrate with SendGrid/Mailgun/etc.
|
|
|
|
$headers = "From: EasyStream <noreply@easystream.com>\r\n";
|
|
$headers .= "Reply-To: noreply@easystream.com\r\n";
|
|
$headers .= "MIME-Version: 1.0\r\n";
|
|
$headers .= "Content-Type: text/html; charset=UTF-8\r\n";
|
|
|
|
return mail($email['email'], $email['subject'], $email['body'], $headers);
|
|
}
|
|
|
|
/**
|
|
* Clean up old notifications (called by cron)
|
|
*/
|
|
public function cleanup($days = 90) {
|
|
$sql = "DELETE FROM db_notifications
|
|
WHERE created_at < DATE_SUB(NOW(), INTERVAL ? DAY)
|
|
AND is_read = 1";
|
|
|
|
$result = $this->db->execute($sql, [$days]);
|
|
|
|
$deleted = $this->db->affectedRows();
|
|
$this->logger->info("Cleaned up $deleted old read notifications (older than $days days)");
|
|
|
|
return $deleted;
|
|
}
|
|
}
|