Files
Krystie f50b493df2 feat: Add comprehensive notification system with email support
- 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
2026-03-30 17:11:08 -07:00

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;
}
}